/* * Copyright (C) 2017 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.server.timezone; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.util.Slog; import java.io.File; import static com.android.server.timezone.PackageStatus.CHECK_COMPLETED_FAILURE; import static com.android.server.timezone.PackageStatus.CHECK_COMPLETED_SUCCESS; import static com.android.server.timezone.PackageStatus.CHECK_STARTED; /** * Storage logic for accessing/mutating the Android system's persistent state related to time zone * update checking. There is expected to be a single instance and all methods synchronized on * {@code this} for thread safety. */ final class PackageStatusStorage { private static final String TAG = "timezone.PackageStatusStorage"; private static final String DATABASE_NAME = "timezonepackagestatus.db"; private static final int DATABASE_VERSION = 1; /** The table name. It will have a single row with _id == {@link #SINGLETON_ID} */ private static final String TABLE = "status"; private static final String COLUMN_ID = "_id"; /** * Column that stores a monotonically increasing lock ID, used to detect concurrent update * issues without on-line locks. Incremented on every write. */ private static final String COLUMN_OPTIMISTIC_LOCK_ID = "optimistic_lock_id"; /** * Column that stores the current "check status" of the time zone update application packages. */ private static final String COLUMN_CHECK_STATUS = "check_status"; /** * Column that stores the version of the time zone rules update application being checked / last * checked. */ private static final String COLUMN_UPDATE_APP_VERSION = "update_app_package_version"; /** * Column that stores the version of the time zone rules data application being checked / last * checked. */ private static final String COLUMN_DATA_APP_VERSION = "data_app_package_version"; /** * The ID of the one row. */ private static final int SINGLETON_ID = 1; private static final int UNKNOWN_PACKAGE_VERSION = -1; private final DatabaseHelper mDatabaseHelper; PackageStatusStorage(Context context) { mDatabaseHelper = new DatabaseHelper(context); } void deleteDatabaseForTests() { SQLiteDatabase.deleteDatabase(mDatabaseHelper.getDatabaseFile()); } /** * Obtain the current check status of the application packages. Returns {@code null} the first * time it is called, or after {@link #resetCheckState()}. */ PackageStatus getPackageStatus() { synchronized (this) { try { return getPackageStatusInternal(); } catch (IllegalArgumentException e) { // This means that data exists in the table but it was bad. Slog.e(TAG, "Package status invalid, resetting and retrying", e); // Reset the storage so it is in a good state again. mDatabaseHelper.recoverFromBadData(); return getPackageStatusInternal(); } } } private PackageStatus getPackageStatusInternal() { String[] columns = { COLUMN_CHECK_STATUS, COLUMN_UPDATE_APP_VERSION, COLUMN_DATA_APP_VERSION }; Cursor cursor = mDatabaseHelper.getReadableDatabase() .query(TABLE, columns, COLUMN_ID + " = ?", new String[] { Integer.toString(SINGLETON_ID) }, null /* groupBy */, null /* having */, null /* orderBy */); if (cursor.getCount() != 1) { Slog.e(TAG, "Unable to find package status from package status row. Rows returned: " + cursor.getCount()); return null; } cursor.moveToFirst(); // Determine check status. if (cursor.isNull(0)) { // This is normal the first time getPackageStatus() is called, or after // resetCheckState(). return null; } int checkStatus = cursor.getInt(0); // Determine package version. if (cursor.isNull(1) || cursor.isNull(2)) { Slog.e(TAG, "Package version information unexpectedly null"); return null; } PackageVersions packageVersions = new PackageVersions(cursor.getInt(1), cursor.getInt(2)); return new PackageStatus(checkStatus, packageVersions); } /** * Generate a new {@link CheckToken} that can be passed to the time zone rules update * application. */ CheckToken generateCheckToken(PackageVersions currentInstalledVersions) { if (currentInstalledVersions == null) { throw new NullPointerException("currentInstalledVersions == null"); } synchronized (this) { Integer optimisticLockId = getCurrentOptimisticLockId(); if (optimisticLockId == null) { Slog.w(TAG, "Unable to find optimistic lock ID from package status row"); // Recover. optimisticLockId = mDatabaseHelper.recoverFromBadData(); } int newOptimisticLockId = optimisticLockId + 1; boolean statusRowUpdated = writeStatusRow( optimisticLockId, newOptimisticLockId, CHECK_STARTED, currentInstalledVersions); if (!statusRowUpdated) { Slog.e(TAG, "Unable to update status to CHECK_STARTED in package status row." + " synchronization failure?"); return null; } return new CheckToken(newOptimisticLockId, currentInstalledVersions); } } /** * Reset the current device state to "unknown". */ void resetCheckState() { synchronized(this) { Integer optimisticLockId = getCurrentOptimisticLockId(); if (optimisticLockId == null) { Slog.w(TAG, "resetCheckState: Unable to find optimistic lock ID from package" + " status row"); // Attempt to recover the storage state. optimisticLockId = mDatabaseHelper.recoverFromBadData(); } int newOptimisticLockId = optimisticLockId + 1; if (!writeStatusRow(optimisticLockId, newOptimisticLockId, null /* status */, null /* packageVersions */)) { Slog.e(TAG, "resetCheckState: Unable to reset package status row," + " newOptimisticLockId=" + newOptimisticLockId); } } } /** * Update the current device state if possible. Returns true if the update was successful. * {@code false} indicates the storage has been changed since the {@link CheckToken} was * generated and the update was discarded. */ boolean markChecked(CheckToken checkToken, boolean succeeded) { synchronized (this) { int optimisticLockId = checkToken.mOptimisticLockId; int newOptimisticLockId = optimisticLockId + 1; int status = succeeded ? CHECK_COMPLETED_SUCCESS : CHECK_COMPLETED_FAILURE; return writeStatusRow(optimisticLockId, newOptimisticLockId, status, checkToken.mPackageVersions); } } // Caller should be synchronized(this) private Integer getCurrentOptimisticLockId() { final String[] columns = { COLUMN_OPTIMISTIC_LOCK_ID }; final String querySelection = COLUMN_ID + " = ?"; final String[] querySelectionArgs = { Integer.toString(SINGLETON_ID) }; SQLiteDatabase database = mDatabaseHelper.getReadableDatabase(); try (Cursor cursor = database.query(TABLE, columns, querySelection, querySelectionArgs, null /* groupBy */, null /* having */, null /* orderBy */)) { if (cursor.getCount() != 1) { Slog.w(TAG, cursor.getCount() + " rows returned, expected exactly one."); return null; } cursor.moveToFirst(); return cursor.getInt(0); } } // Caller should be synchronized(this) private boolean writeStatusRow(int optimisticLockId, int newOptimisticLockId, Integer status, PackageVersions packageVersions) { if ((status == null) != (packageVersions == null)) { throw new IllegalArgumentException( "Provide both status and packageVersions, or neither."); } SQLiteDatabase database = mDatabaseHelper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put(COLUMN_OPTIMISTIC_LOCK_ID, newOptimisticLockId); if (status == null) { values.putNull(COLUMN_CHECK_STATUS); values.put(COLUMN_UPDATE_APP_VERSION, UNKNOWN_PACKAGE_VERSION); values.put(COLUMN_DATA_APP_VERSION, UNKNOWN_PACKAGE_VERSION); } else { values.put(COLUMN_CHECK_STATUS, status); values.put(COLUMN_UPDATE_APP_VERSION, packageVersions.mUpdateAppVersion); values.put(COLUMN_DATA_APP_VERSION, packageVersions.mDataAppVersion); } String updateSelection = COLUMN_ID + " = ? AND " + COLUMN_OPTIMISTIC_LOCK_ID + " = ?"; String[] updateSelectionArgs = { Integer.toString(SINGLETON_ID), Integer.toString(optimisticLockId) }; int count = database.update(TABLE, values, updateSelection, updateSelectionArgs); if (count > 1) { // This has to be because of corruption: there should only ever be one row. Slog.w(TAG, "writeStatusRow: " + count + " rows updated, expected exactly one."); // Reset the table. mDatabaseHelper.recoverFromBadData(); } // 1 is the success case. 0 rows updated means the row is missing or the optimistic lock ID // was not as expected, this could be because of corruption but is most likely due to an // optimistic lock failure. Callers can decide on a case-by-case basis. return count == 1; } /** Only used during tests to force an empty table. */ void deleteRowForTests() { mDatabaseHelper.getWritableDatabase().delete(TABLE, null, null); } /** Only used during tests to force a known table state. */ public void forceCheckStateForTests(int checkStatus, PackageVersions packageVersions) { int optimisticLockId = getCurrentOptimisticLockId(); writeStatusRow(optimisticLockId, optimisticLockId, checkStatus, packageVersions); } static class DatabaseHelper extends SQLiteOpenHelper { private final Context mContext; public DatabaseHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); mContext = context; } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE " + TABLE + " (" + "_id INTEGER PRIMARY KEY," + COLUMN_OPTIMISTIC_LOCK_ID + " INTEGER NOT NULL," + COLUMN_CHECK_STATUS + " INTEGER," + COLUMN_UPDATE_APP_VERSION + " INTEGER NOT NULL," + COLUMN_DATA_APP_VERSION + " INTEGER NOT NULL" + ");"); insertInitialRowState(db); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int currentVersion) { // no-op: nothing to upgrade } /** Recover the initial data row state, returning the new current optimistic lock ID */ int recoverFromBadData() { // Delete the table content. SQLiteDatabase writableDatabase = getWritableDatabase(); writableDatabase.delete(TABLE, null /* whereClause */, null /* whereArgs */); // Insert the initial content. return insertInitialRowState(writableDatabase); } /** Insert the initial data row, returning the optimistic lock ID */ private static int insertInitialRowState(SQLiteDatabase db) { // Doesn't matter what it is, but we avoid the obvious starting value each time the row // is reset to ensure that old tokens are unlikely to work. final int initialOptimisticLockId = (int) System.currentTimeMillis(); // Insert the one row. ContentValues values = new ContentValues(); values.put(COLUMN_ID, SINGLETON_ID); values.put(COLUMN_OPTIMISTIC_LOCK_ID, initialOptimisticLockId); values.putNull(COLUMN_CHECK_STATUS); values.put(COLUMN_UPDATE_APP_VERSION, UNKNOWN_PACKAGE_VERSION); values.put(COLUMN_DATA_APP_VERSION, UNKNOWN_PACKAGE_VERSION); long id = db.insert(TABLE, null, values); if (id == -1) { Slog.w(TAG, "insertInitialRow: could not insert initial row, id=" + id); return -1; } return initialOptimisticLockId; } File getDatabaseFile() { return mContext.getDatabasePath(DATABASE_NAME); } } }