Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 7eb75dff authored by Jae Seo's avatar Jae Seo
Browse files

TIF: Move watch history logging to TvProvider

There are only two kinds of watch events that can happen on the system:
 1. The current TV input session is tuned to a new channel.
 2. The session is released for some reason.
The former indicates the end of the previous log entry, if any, followed by the
start of a new entry. The latter indicates the end of the most recent entry for
the given session. Here the system supplies the database the smallest set of
information only that is sufficient to consolidate the log entries while
minimizing database operations in the system service.

Bug: 14791151, Bug: 15921205, Bug: 15988715, Bug: 16831555
Change-Id: Ifcb1a1273c14ee234919d819d082b1004eb92ebc
parent 97f55048
Loading
Loading
Loading
Loading
+23 −8
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.net.Uri;
import android.os.IBinder;
import android.provider.BaseColumns;
import android.util.ArraySet;

@@ -795,7 +796,7 @@ public final class TvContract {
        public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/program";

        /**
         * The ID of the TV channel that contains this TV program.
         * The ID of the TV channel that provides this TV program.
         * <p>
         * This is a part of the channel URI and matches to {@link BaseColumns#_ID}.
         * </p><p>
@@ -1109,6 +1110,7 @@ public final class TvContract {
     * to this table.
     * @hide
     */
    @SystemApi
    public static final class WatchedPrograms implements BaseTvColumns {

        /** The content:// style URI for this table. */
@@ -1141,7 +1143,7 @@ public final class TvContract {
        public static final String COLUMN_WATCH_END_TIME_UTC_MILLIS = "watch_end_time_utc_millis";

        /**
         * The channel ID that contains this TV program.
         * The ID of the TV channel that provides this TV program.
         * <p>
         * Type: INTEGER (long)
         * </p>
@@ -1181,17 +1183,30 @@ public final class TvContract {
        public static final String COLUMN_DESCRIPTION = "description";

        /**
         * Extra parameters of the tune operation.
         * Extra parameters given to {@link TvInputService.Session#tune(Uri, android.os.Bundle)
         * TvInputService.Session.tune(Uri, android.os.Bundle)} when tuning to the channel that
         * provides this TV program. (Used internally.)
         * <p>
         * This column contains an encoded string that represents comma-separated key-value pairs of
         * the tune parameters. (Ex. "[key1]=[value1], [key2]=[value2]"). '%' is used as an escape
         * character for '%', '=', and ','.
         * </p><p>
         * Type: TEXT
         * </p>
         */
        public static final String COLUMN_INTERNAL_TUNE_PARAMS = "tune_params";

        /**
         * The session token of this TV program. (Used internally.)
         * <p>
         * This column contains an encoded string which is comma-separated key-value pairs.
         * (Ex. "[key1]=[value1], [key2]=[value2]"). COLUMN_TUNE_PARAMS will use '%' as an escape
         * character for the characters of '%', '=', and ','.
         * This contains a String representation of {@link IBinder} for
         * {@link TvInputService.Session} that provides the current TV program. It is used
         * internally to distinguish watched programs entries from different TV input sessions.
         * </p><p>
         * Type: TEXT
         * </p>
         * @see TvInputManager.Session.tune(Uri, Bundle)
         */
        public static final String COLUMN_TUNE_PARAMS = "tune_params";
        public static final String COLUMN_INTERNAL_SESSION_TOKEN = "session_token";

        private WatchedPrograms() {}
    }
+84 −235
Original line number Diff line number Diff line
@@ -35,7 +35,6 @@ import android.content.ServiceConnection;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.ServiceInfo;
import android.database.Cursor;
import android.graphics.Rect;
import android.hardware.hdmi.HdmiDeviceInfo;
import android.media.tv.ITvInputClient;
@@ -108,14 +107,14 @@ public final class TvInputManagerService extends SystemService {
    // A map from user id to UserState.
    private final SparseArray<UserState> mUserStates = new SparseArray<UserState>();

    private final Handler mLogHandler;
    private final WatchLogHandler mWatchLogHandler;

    public TvInputManagerService(Context context) {
        super(context);

        mContext = context;
        mContentResolver = context.getContentResolver();
        mLogHandler = new LogHandler(IoThread.get().getLooper());
        mWatchLogHandler = new WatchLogHandler(IoThread.get().getLooper());

        mTvInputHardwareManager = new TvInputHardwareManager(context, new HardwareListener());

@@ -717,14 +716,6 @@ public final class TvInputManagerService extends SystemService {
            return;
        }

        // Close the open log entry, if any.
        if (sessionState.mLogUri != null) {
            SomeArgs args = SomeArgs.obtain();
            args.arg1 = sessionState.mLogUri;
            args.arg2 = System.currentTimeMillis();
            mLogHandler.obtainMessage(LogHandler.MSG_CLOSE_ENTRY, args).sendToTarget();
        }

        // Also remove the session token from the session token list of the current client and
        // service.
        ClientState clientState = userState.clientStateMap.get(sessionState.mClient.asBinder());
@@ -743,6 +734,12 @@ public final class TvInputManagerService extends SystemService {
            }
        }
        updateServiceConnectionLocked(sessionState.mInfo.getComponent(), userId);

        // Log the end of watch.
        SomeArgs args = SomeArgs.obtain();
        args.arg1 = sessionToken;
        args.arg2 = System.currentTimeMillis();
        mWatchLogHandler.obtainMessage(WatchLogHandler.MSG_LOG_WATCH_END, args).sendToTarget();
    }

    private void notifyInputAddedLocked(UserState userState, String inputId) {
@@ -1231,41 +1228,19 @@ public final class TvInputManagerService extends SystemService {
                            // Do not log the watch history for passthrough inputs.
                            return;
                        }
                        long currentTime = System.currentTimeMillis();
                        long channelId = ContentUris.parseId(channelUri);

                        // Close the open log entry first, if any.
                        UserState userState = getUserStateLocked(resolvedUserId);
                        SessionState sessionState = userState.sessionStateMap.get(sessionToken);
                        if (sessionState.mLogUri != null) {
                            SomeArgs args = SomeArgs.obtain();
                            args.arg1 = sessionState.mLogUri;
                            args.arg2 = currentTime;
                            mLogHandler.obtainMessage(LogHandler.MSG_CLOSE_ENTRY, args)
                                    .sendToTarget();
                        }

                        // Create a log entry and fill it later.
                        String packageName = sessionState.mInfo.getServiceInfo().packageName;
                        ContentValues values = new ContentValues();
                        values.put(TvContract.WatchedPrograms.COLUMN_PACKAGE_NAME, packageName);
                        values.put(TvContract.WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
                                currentTime);
                        values.put(TvContract.WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS, 0);
                        values.put(TvContract.WatchedPrograms.COLUMN_CHANNEL_ID, channelId);
                        if (params != null) {
                            values.put(TvContract.WatchedPrograms.COLUMN_TUNE_PARAMS,
                                    encodeTuneParams(params));
                        }

                        sessionState.mLogUri = mContentResolver.insert(
                                TvContract.WatchedPrograms.CONTENT_URI, values);
                        // Log the start of watch.
                        SomeArgs args = SomeArgs.obtain();
                        args.arg1 = sessionState.mLogUri;
                        args.arg2 = ContentUris.parseId(channelUri);
                        args.arg3 = currentTime;
                        args.arg4 = sessionState;
                        mLogHandler.obtainMessage(LogHandler.MSG_OPEN_ENTRY, args).sendToTarget();
                        args.arg1 = sessionState.mInfo.getComponent().getPackageName();
                        args.arg2 = System.currentTimeMillis();
                        args.arg3 = ContentUris.parseId(channelUri);
                        args.arg4 = params;
                        args.arg5 = sessionToken;
                        mWatchLogHandler.obtainMessage(WatchLogHandler.MSG_LOG_WATCH_START, args)
                                .sendToTarget();
                    } catch (RemoteException e) {
                        Slog.e(TAG, "error in tune", e);
                        return;
@@ -1691,39 +1666,6 @@ public final class TvInputManagerService extends SystemService {
                }
            }
        }

        private String encodeTuneParams(Bundle tuneParams) {
            StringBuilder builder = new StringBuilder();
            Set<String> keySet = tuneParams.keySet();
            Iterator<String> it = keySet.iterator();
            while (it.hasNext()) {
                String key = it.next();
                Object value = tuneParams.get(key);
                if (value == null) {
                    continue;
                }
                builder.append(replaceEscapeCharacters(key));
                builder.append("=");
                builder.append(replaceEscapeCharacters(value.toString()));
                if (it.hasNext()) {
                    builder.append(", ");
                }
            }
            return builder.toString();
        }

        private String replaceEscapeCharacters(String src) {
            final char ESCAPE_CHARACTER = '%';
            final String ENCODING_TARGET_CHARACTERS = "%=,";
            StringBuilder builder = new StringBuilder();
            for (char ch : src.toCharArray()) {
                if (ENCODING_TARGET_CHARACTERS.indexOf(ch) >= 0) {
                    builder.append(ESCAPE_CHARACTER);
                }
                builder.append(ch);
            }
            return builder.toString();
        }
    }

    private static final class TvInputState {
@@ -2062,195 +2004,102 @@ public final class TvInputManagerService extends SystemService {
        }
    }

    private final class LogHandler extends Handler {
        private static final int MSG_OPEN_ENTRY = 1;
        private static final int MSG_UPDATE_ENTRY = 2;
        private static final int MSG_CLOSE_ENTRY = 3;
    private final class WatchLogHandler extends Handler {
        // There are only two kinds of watch events that can happen on the system:
        // 1. The current TV input session is tuned to a new channel.
        // 2. The session is released for some reason.
        // The former indicates the end of the previous log entry, if any, followed by the start of
        // a new entry. The latter indicates the end of the most recent entry for the given session.
        // Here the system supplies the database the smallest set of information only that is
        // sufficient to consolidate the log entries while minimizing database operations in the
        // system service.
        private static final int MSG_LOG_WATCH_START = 1;
        private static final int MSG_LOG_WATCH_END = 2;

        public LogHandler(Looper looper) {
        public WatchLogHandler(Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MSG_OPEN_ENTRY: {
                case MSG_LOG_WATCH_START: {
                    SomeArgs args = (SomeArgs) msg.obj;
                    Uri uri = (Uri) args.arg1;
                    long channelId = (long) args.arg2;
                    long time = (long) args.arg3;
                    SessionState sessionState = (SessionState) args.arg4;
                    onOpenEntry(uri, channelId, time, sessionState);
                    args.recycle();
                    return;
                    String packageName = (String) args.arg1;
                    long watchStartTime = (long) args.arg2;
                    long channelId = (long) args.arg3;
                    Bundle tuneParams = (Bundle) args.arg4;
                    IBinder sessionToken = (IBinder) args.arg5;

                    ContentValues values = new ContentValues();
                    values.put(TvContract.WatchedPrograms.COLUMN_PACKAGE_NAME, packageName);
                    values.put(TvContract.WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS,
                            watchStartTime);
                    values.put(TvContract.WatchedPrograms.COLUMN_CHANNEL_ID, channelId);
                    if (tuneParams != null) {
                        values.put(TvContract.WatchedPrograms.COLUMN_INTERNAL_TUNE_PARAMS,
                                encodeTuneParams(tuneParams));
                    }
                case MSG_UPDATE_ENTRY: {
                    SomeArgs args = (SomeArgs) msg.obj;
                    Uri uri = (Uri) args.arg1;
                    long channelId = (long) args.arg2;
                    long time = (long) args.arg3;
                    SessionState sessionState = (SessionState) args.arg4;
                    onUpdateEntry(uri, channelId, time, sessionState);
                    values.put(TvContract.WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN,
                            sessionToken.toString());

                    mContentResolver.insert(TvContract.WatchedPrograms.CONTENT_URI, values);
                    args.recycle();
                    return;
                }
                case MSG_CLOSE_ENTRY: {
                case MSG_LOG_WATCH_END: {
                    SomeArgs args = (SomeArgs) msg.obj;
                    Uri uri = (Uri) args.arg1;
                    long time = (long) args.arg2;
                    onCloseEntry(uri, time);
                    args.recycle();
                    return;
                }
                default: {
                    Slog.w(TAG, "Unhandled message code: " + msg.what);
                    return;
                }
            }
        }
                    IBinder sessionToken = (IBinder) args.arg1;
                    long watchEndTime = (long) args.arg2;

        private void onOpenEntry(Uri logUri, long channelId, long watchStarttime,
                SessionState sessionState) {
            if (!isChannelSearchable(channelId)) {
                // Do not log anything about non-searchable channels.
                synchronized (mLock) {
                    sessionState.mLogUri = null;
                }
                mContentResolver.delete(logUri, null, null);
                return;
            }

            String[] projection = {
                    TvContract.Programs.COLUMN_TITLE,
                    TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
                    TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS,
                    TvContract.Programs.COLUMN_SHORT_DESCRIPTION
            };
            String selection = TvContract.Programs.COLUMN_CHANNEL_ID + "=? AND "
                    + TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS + "<=? AND "
                    + TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS + ">?";
            String[] selectionArgs = {
                    String.valueOf(channelId),
                    String.valueOf(watchStarttime),
                    String.valueOf(watchStarttime)
            };
            String sortOrder = TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS + " ASC";
            Cursor cursor = null;
            try {
                cursor = mContentResolver.query(TvContract.Programs.CONTENT_URI, projection,
                        selection, selectionArgs, sortOrder);
                if (cursor != null && cursor.moveToNext()) {
                    ContentValues values = new ContentValues();
                    values.put(TvContract.WatchedPrograms.COLUMN_TITLE, cursor.getString(0));
                    values.put(TvContract.WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS,
                            cursor.getLong(1));
                    long endTime = cursor.getLong(2);
                    values.put(TvContract.WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS, endTime);
                    values.put(TvContract.WatchedPrograms.COLUMN_DESCRIPTION, cursor.getString(3));
                    mContentResolver.update(logUri, values, null, null);

                    // Schedule an update when the current program ends.
                    SomeArgs args = SomeArgs.obtain();
                    args.arg1 = logUri;
                    args.arg2 = channelId;
                    args.arg3 = endTime;
                    args.arg4 = sessionState;
                    Message msg = obtainMessage(LogHandler.MSG_UPDATE_ENTRY, args);
                    sendMessageDelayed(msg, endTime - System.currentTimeMillis());
                }
            } finally {
                if (cursor != null) {
                    cursor.close();
                }
            }
        }
                    values.put(TvContract.WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS,
                            watchEndTime);
                    values.put(TvContract.WatchedPrograms.COLUMN_INTERNAL_SESSION_TOKEN,
                            sessionToken.toString());

        private void onUpdateEntry(Uri uri, long channelId, long time, SessionState sessionState) {
            String[] projection = {
                    TvContract.WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS
            };
            Cursor cursor = null;
            try {
                cursor = mContentResolver.query(uri, projection, null, null, null);
                if (cursor != null && cursor.moveToNext()) {
                    long watchEndTime = cursor.getLong(0);
                    // Do nothing if the current log entry is already closed.
                    if (watchEndTime > 0) {
                    mContentResolver.insert(TvContract.WatchedPrograms.CONTENT_URI, values);
                    args.recycle();
                    return;
                }

                    // Update the watch end time for the current log entry.
                    ContentValues values = new ContentValues();
                    values.put(TvContract.WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS, time);
                    int c = mContentResolver.update(uri, values, null, null);
                } else {
                    // The record has been deleted.
                    synchronized (mLock) {
                        if (!uri.equals(sessionState.mLogUri)) {
                            // If the deleted record is not for the current channel, do not re-open
                            // a log entry for the next program.
                default: {
                    Slog.w(TAG, "Unhandled message code: " + msg.what);
                    return;
                }
            }
        }
                if (cursor != null) {
                    cursor.close();
                    cursor = null;
                }

                // The current program has just ended. Create a new log entry for the next program.
                uri = ContentUris.withAppendedId(TvContract.Channels.CONTENT_URI, channelId);
                projection = new String[] {
                        TvContract.Channels.COLUMN_PACKAGE_NAME
                };
                cursor = mContentResolver.query(uri, projection, null, null, null);
                if (cursor != null && cursor.moveToNext()) {
                    ContentValues values = new ContentValues();
                    values.put(TvContract.WatchedPrograms.COLUMN_PACKAGE_NAME, cursor.getString(0));
                    values.put(TvContract.WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS, time);
                    values.put(TvContract.WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS, 0);
                    values.put(TvContract.WatchedPrograms.COLUMN_CHANNEL_ID, channelId);
                    Uri newUri = mContentResolver.insert(TvContract.WatchedPrograms.CONTENT_URI,
                            values);

                    synchronized (mLock) {
                        sessionState.mLogUri = newUri;
                    }

                    // Re-open the current log entry with the next program information.
                    onOpenEntry(newUri, channelId, time, sessionState);
                }
            } finally {
                if (cursor != null) {
                    cursor.close();
        private String encodeTuneParams(Bundle tuneParams) {
            StringBuilder builder = new StringBuilder();
            Set<String> keySet = tuneParams.keySet();
            Iterator<String> it = keySet.iterator();
            while (it.hasNext()) {
                String key = it.next();
                Object value = tuneParams.get(key);
                if (value == null) {
                    continue;
                }
                builder.append(replaceEscapeCharacters(key));
                builder.append("=");
                builder.append(replaceEscapeCharacters(value.toString()));
                if (it.hasNext()) {
                    builder.append(", ");
                }
            }

        private void onCloseEntry(Uri uri, long watchEndTime) {
            ContentValues values = new ContentValues();
            values.put(TvContract.WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS, watchEndTime);
            mContentResolver.update(uri, values, null, null);
            return builder.toString();
        }

        private boolean isChannelSearchable(long channelId) {
            String[] projection = { TvContract.Channels.COLUMN_SEARCHABLE };
            String selection = TvContract.Channels._ID + "=?";
            String[] selectionArgs = { String.valueOf(channelId) };
            Cursor cursor = null;
            try {
                cursor = mContentResolver.query(TvContract.Channels.CONTENT_URI, projection,
                        selection, selectionArgs, null);
                if (cursor != null && cursor.moveToNext()) {
                    return cursor.getLong(0) == 1 ? true : false;
                }
            } finally {
                if (cursor != null) {
                    cursor.close();
        private String replaceEscapeCharacters(String src) {
            final char ESCAPE_CHARACTER = '%';
            final String ENCODING_TARGET_CHARACTERS = "%=,";
            StringBuilder builder = new StringBuilder();
            for (char ch : src.toCharArray()) {
                if (ENCODING_TARGET_CHARACTERS.indexOf(ch) >= 0) {
                    builder.append(ESCAPE_CHARACTER);
                }
                builder.append(ch);
            }
            // Unless explicitly specified non-searchable, by default the channel is searchable.
            return true;
            return builder.toString();
        }
    }