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

Commit d8353986 authored by Darrell Shi's avatar Darrell Shi
Browse files

Backup & restore communal state

This change introduces a backup helper responsible for backing up and
restoring the communal database. At backup, the helper requests a
snapshot of the database in the form of a proto buffer, and writes it to
the backup output stream. And at restore, the helper reads data from the
input stream and writes it to a file for later use.

Test: atest CommunalBackupHelperTest
Test: atest CommunalBackupUtilsTest
Test: manually; see instructions at go/glanceable-hub-br
Bug: 309809222
Flag: ACONFIG com.android.systemui.communal_hub TEAMFOOD
Change-Id: Ieb59f6018708213b70d8e12de12741927783b4cb
parent a8b13c95
Loading
Loading
Loading
Loading
+8 −2
Original line number Diff line number Diff line
@@ -28,8 +28,9 @@ import android.os.ParcelFileDescriptor
import android.os.UserHandle
import android.util.Log
import com.android.app.tracing.traceSection
import com.android.systemui.Flags.communalHub
import com.android.systemui.backup.BackupHelper.Companion.ACTION_RESTORE_FINISHED
import com.android.systemui.communal.data.backup.CommunalBackupHelper
import com.android.systemui.communal.data.backup.CommunalBackupUtils
import com.android.systemui.communal.domain.backup.CommunalPrefsBackupHelper
import com.android.systemui.controls.controller.AuxiliaryPersistenceWrapper
import com.android.systemui.controls.controller.ControlsFavoritePersistenceWrapper
@@ -59,6 +60,7 @@ open class BackupHelper : BackupAgentHelper() {
            "systemui.keyguard.quickaffordance.shared_preferences"
        private const val COMMUNAL_PREFS_BACKUP_KEY =
            "systemui.communal.shared_preferences"
        private const val COMMUNAL_STATE_BACKUP_KEY = "systemui.communal_state"
        val controlsDataLock = Any()
        const val ACTION_RESTORE_FINISHED = "com.android.systemui.backup.RESTORE_FINISHED"
        const val PERMISSION_SELF = "com.android.systemui.permission.SELF"
@@ -89,6 +91,10 @@ open class BackupHelper : BackupAgentHelper() {
                    userId = userHandle.identifier,
                )
            )
            addHelper(
                COMMUNAL_STATE_BACKUP_KEY,
                CommunalBackupHelper(userHandle, CommunalBackupUtils(context = this)),
            )
        }
    }

@@ -116,7 +122,7 @@ open class BackupHelper : BackupAgentHelper() {
    }

    private fun communalEnabled(): Boolean {
        return resources.getBoolean(R.bool.config_communalServiceEnabled) && communalHub()
        return resources.getBoolean(R.bool.config_communalServiceEnabled)
    }

    /**
+114 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.systemui.communal.data.backup

import android.app.backup.BackupDataInputStream
import android.app.backup.BackupDataOutput
import android.app.backup.BackupHelper
import android.os.ParcelFileDescriptor
import android.os.UserHandle
import android.util.Log
import com.android.systemui.Flags.communalHub
import com.android.systemui.communal.proto.toByteArray
import java.io.IOException

/** Helps with backup & restore of the communal hub widgets. */
class CommunalBackupHelper(
    private val userHandle: UserHandle,
    private val communalBackupUtils: CommunalBackupUtils,
) : BackupHelper {

    override fun performBackup(
        oldState: ParcelFileDescriptor?,
        data: BackupDataOutput?,
        newState: ParcelFileDescriptor?
    ) {
        if (!communalHub()) {
            Log.d(TAG, "Skipping backup. Communal not enabled")
            return
        }

        if (data == null) {
            Log.e(TAG, "Backup failed. Data is null")
            return
        }

        if (!userHandle.isSystem) {
            Log.d(TAG, "Backup skipped for non-system user")
            return
        }

        val state = communalBackupUtils.getCommunalHubState()
        Log.i(TAG, "Backing up communal state: $state")

        val bytes = state.toByteArray()
        try {
            data.writeEntityHeader(ENTITY_KEY, bytes.size)
            data.writeEntityData(bytes, bytes.size)
        } catch (e: IOException) {
            Log.e(TAG, "Backup failed while writing data: ${e.localizedMessage}")
            return
        }

        Log.i(TAG, "Backup complete")
    }

    override fun restoreEntity(data: BackupDataInputStream?) {
        if (data == null) {
            Log.e(TAG, "Restore failed. Data is null")
            return
        }

        if (!userHandle.isSystem) {
            Log.d(TAG, "Restore skipped for non-system user")
            return
        }

        if (data.key != ENTITY_KEY) {
            Log.d(TAG, "Restore skipped due to mismatching entity key")
            return
        }

        val dataSize = data.size()
        val bytes = ByteArray(dataSize)
        try {
            data.read(bytes, /* offset= */ 0, dataSize)
        } catch (e: IOException) {
            Log.e(TAG, "Restore failed while reading data: ${e.localizedMessage}")
            return
        }

        try {
            communalBackupUtils.writeBytesToDisk(bytes)
        } catch (e: Exception) {
            Log.e(TAG, "Restore failed while writing to disk: ${e.localizedMessage}")
            return
        }

        Log.i(TAG, "Restore complete")
    }

    override fun writeNewStateDescription(newState: ParcelFileDescriptor?) {
        // Do nothing because there is no partial backup
    }

    companion object {
        private const val TAG = "CommunalBackupHelper"

        const val ENTITY_KEY = "communal_hub_state"
    }
}
+62 −0
Original line number Diff line number Diff line
@@ -20,6 +20,11 @@ import android.content.Context
import androidx.annotation.WorkerThread
import com.android.systemui.communal.data.db.CommunalDatabase
import com.android.systemui.communal.nano.CommunalHubState
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking

@@ -48,4 +53,61 @@ class CommunalBackupUtils(
        }
        return CommunalHubState().apply { widgets = widgetsState.toTypedArray() }
    }

    /**
     * Writes [data] to disk as a file as [FILE_NAME], overwriting existing content if any.
     *
     * @throws FileNotFoundException if the file exists but is a directory rather than a regular
     *   file, does not exist but cannot be created, or cannot be opened for any other reason.
     * @throws SecurityException if write access is denied.
     * @throws IOException if writing fails.
     */
    @WorkerThread
    fun writeBytesToDisk(data: ByteArray) {
        val output = FileOutputStream(getFile())
        output.write(data)
        output.close()
    }

    /**
     * Reads bytes from [FILE_NAME], and throws if file does not exist.
     *
     * @throws FileNotFoundException if file does not exist.
     * @throws SecurityException if read access is denied.
     * @throws IOException if reading fails.
     */
    @WorkerThread
    fun readBytesFromDisk(): ByteArray {
        val input = FileInputStream(getFile())
        val bytes = input.readAllBytes()
        input.close()

        return bytes
    }

    /**
     * Removes the bytes written to disk at [FILE_NAME].
     *
     * @return True if and only if the file is successfully deleted
     * @throws SecurityException if permission is denied
     */
    @WorkerThread
    fun clear(): Boolean {
        return getFile().delete()
    }

    /** Whether [FILE_NAME] exists. */
    @WorkerThread
    fun fileExists(): Boolean {
        return getFile().exists()
    }

    @WorkerThread
    private fun getFile(): File {
        return File(context.filesDir, FILE_NAME)
    }

    companion object {
        private const val FILE_NAME = "communal_restore"
    }
}
+34 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.systemui.communal.proto

import com.android.systemui.communal.nano.CommunalHubState
import com.google.protobuf.nano.InvalidProtocolBufferNanoException

/** Converts a [CommunalHubState] to bytes. */
fun CommunalHubState.toByteArray(): ByteArray {
    return CommunalHubState.toByteArray(this)
}

/**
 * Converts bytes to a [CommunalHubState].
 *
 * @throws InvalidProtocolBufferNanoException if parsing fails.
 */
fun ByteArray.toCommunalHubState(): CommunalHubState {
    return CommunalHubState.parseFrom(this)
}
+155 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.systemui.communal.data.backup

import android.app.backup.BackupDataInput
import android.app.backup.BackupDataInputStream
import android.app.backup.BackupDataOutput
import android.os.UserHandle
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.Flags
import com.android.systemui.SysuiTestCase
import com.android.systemui.communal.data.backup.CommunalBackupUtilsTest.Companion.represents
import com.android.systemui.communal.data.backup.CommunalBackupUtilsTest.FakeWidgetMetadata
import com.android.systemui.communal.data.db.CommunalDatabase
import com.android.systemui.communal.data.db.CommunalWidgetDao
import com.android.systemui.communal.proto.toCommunalHubState
import com.android.systemui.lifecycle.InstantTaskExecutorRule
import com.google.common.truth.Truth.assertThat
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class CommunalBackupHelperTest : SysuiTestCase() {
    @JvmField @Rule val instantTaskExecutor = InstantTaskExecutorRule()

    private lateinit var database: CommunalDatabase
    private lateinit var dao: CommunalWidgetDao
    private lateinit var backupUtils: CommunalBackupUtils

    // Temporary file used for storing backed-up data.
    private lateinit var backupDataFile: File

    private lateinit var underTest: CommunalBackupHelper

    @Before
    fun setup() {
        database =
            Room.inMemoryDatabaseBuilder(context, CommunalDatabase::class.java)
                .allowMainThreadQueries()
                .build()
        CommunalDatabase.setInstance(database)

        dao = database.communalWidgetDao()
        backupUtils = CommunalBackupUtils(context)

        backupDataFile = File(context.cacheDir, "backup_data_file")

        underTest = CommunalBackupHelper(UserHandle.SYSTEM, backupUtils)
    }

    @After
    fun teardown() {
        backupDataFile.delete()
        database.close()
    }

    @Test
    @EnableFlags(Flags.FLAG_COMMUNAL_HUB)
    fun backupAndRestoreCommunalHub() {
        val expectedWidgets = setUpDatabase()

        underTest.performBackup(oldState = null, data = getBackupDataOutput(), newState = null)
        underTest.restoreEntity(getBackupDataInputStream())

        // Verify restored state matches backed-up state
        val restoredState = backupUtils.readBytesFromDisk().toCommunalHubState()
        val restoredWidgets = restoredState.widgets.toList()
        assertThat(restoredWidgets)
            .comparingElementsUsing(represents)
            .containsExactlyElementsIn(expectedWidgets)
    }

    @Test
    @DisableFlags(Flags.FLAG_COMMUNAL_HUB)
    fun backup_skippedWhenCommunalDisabled() {
        setUpDatabase()

        underTest.performBackup(oldState = null, data = getBackupDataOutput(), newState = null)

        // Verify nothing written to the backup
        assertThat(backupDataFile.length()).isEqualTo(0)
    }

    @Test
    @EnableFlags(Flags.FLAG_COMMUNAL_HUB)
    fun backup_skippedForNonSystemUser() {
        setUpDatabase()

        val helper = CommunalBackupHelper(UserHandle.CURRENT, backupUtils)
        helper.performBackup(oldState = null, data = getBackupDataOutput(), newState = null)

        // Verify nothing written to the backup
        assertThat(backupDataFile.length()).isEqualTo(0)
    }

    private fun setUpDatabase(): List<FakeWidgetMetadata> {
        return listOf(
                FakeWidgetMetadata(11, "com.android.fakePackage1/fakeWidget1", 3),
                FakeWidgetMetadata(12, "com.android.fakePackage2/fakeWidget2", 2),
                FakeWidgetMetadata(13, "com.android.fakePackage3/fakeWidget3", 1),
            )
            .onEach { dao.addWidget(it.widgetId, it.componentName, it.rank) }
    }

    private fun getBackupDataInputStream(): BackupDataInputStream {
        val input = BackupDataInput(FileInputStream(backupDataFile).fd).apply { readNextHeader() }

        // Construct BackupDataInputStream using reflection because its constructor is package
        // private
        val inputStream = BackupDataInputStream::class.constructors.first().call(input)

        // Set key
        with(inputStream.javaClass.getDeclaredField("key")) {
            isAccessible = true
            set(inputStream, input.key)
        }

        // Set dataSize
        with(inputStream.javaClass.getDeclaredField("dataSize")) {
            isAccessible = true
            set(inputStream, input.dataSize)
        }

        return inputStream
    }

    private fun getBackupDataOutput(): BackupDataOutput {
        return BackupDataOutput(FileOutputStream(backupDataFile).fd)
    }
}
Loading