diff --git a/packages/SettingsLib/Spa/spa/Android.bp b/packages/SettingsLib/Spa/spa/Android.bp index 037b45e6732ac..2eaa73e8b21ca 100644 --- a/packages/SettingsLib/Spa/spa/Android.bp +++ b/packages/SettingsLib/Spa/spa/Android.bp @@ -24,6 +24,9 @@ android_library { srcs: ["src/**/*.kt"], static_libs: [ + "androidx.slice_slice-builders", + "androidx.slice_slice-core", + "androidx.slice_slice-view", "androidx.compose.material3_material3", "androidx.compose.material_material-icons-extended", "androidx.compose.runtime_runtime", diff --git a/packages/SettingsLib/Spa/spa/build.gradle b/packages/SettingsLib/Spa/spa/build.gradle index 7a20c747249b5..5daea91fb221b 100644 --- a/packages/SettingsLib/Spa/spa/build.gradle +++ b/packages/SettingsLib/Spa/spa/build.gradle @@ -55,6 +55,9 @@ android { dependencies { api "androidx.appcompat:appcompat:1.7.0-alpha01" + api "androidx.slice:slice-builders:1.1.0-alpha02" + api "androidx.slice:slice-core:1.1.0-alpha02" + api "androidx.slice:slice-view:1.1.0-alpha02" api "androidx.compose.material3:material3:1.1.0-alpha01" api "androidx.compose.material:material-icons-extended:$jetpack_compose_version" api "androidx.compose.runtime:runtime-livedata:$jetpack_compose_version" diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/SpaSearchProvider.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/SpaSearchProvider.kt index 35b9c0fbd16b4..3689e4e66d11b 100644 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/SpaSearchProvider.kt +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/SpaSearchProvider.kt @@ -119,7 +119,7 @@ class SpaSearchProvider : ContentProvider() { val entryRepository by spaEnvironment.entryRepository val cursor = MatrixCursor(QueryEnum.SEARCH_IMMUTABLE_STATUS_DATA_QUERY.getColumns()) for (entry in entryRepository.getAllEntries()) { - if (!entry.isAllowSearch || entry.mutableStatus) continue + if (!entry.isAllowSearch || entry.hasMutableStatus) continue fetchStatusData(entry, cursor) } return cursor @@ -129,7 +129,7 @@ class SpaSearchProvider : ContentProvider() { val entryRepository by spaEnvironment.entryRepository val cursor = MatrixCursor(QueryEnum.SEARCH_MUTABLE_STATUS_DATA_QUERY.getColumns()) for (entry in entryRepository.getAllEntries()) { - if (!entry.isAllowSearch || !entry.mutableStatus) continue + if (!entry.isAllowSearch || !entry.hasMutableStatus) continue fetchStatusData(entry, cursor) } return cursor diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/SpaSliceBroadcastReceiver.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/SpaSliceBroadcastReceiver.kt new file mode 100644 index 0000000000000..8c038c811bb0e --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/SpaSliceBroadcastReceiver.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2022 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.settingslib.spa.framework + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory + +class SpaSliceBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val sliceRepository = SpaEnvironmentFactory.instance.sliceDataRepository + val sliceUri = intent?.data ?: return + val sliceData = sliceRepository.getActiveSliceData(sliceUri) ?: return + sliceData.doAction() + } +} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/SpaSliceProvider.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/SpaSliceProvider.kt new file mode 100644 index 0000000000000..d800e507b35a4 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/SpaSliceProvider.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2022 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.settingslib.spa.framework + +import android.net.Uri +import android.util.Log +import androidx.lifecycle.Observer +import androidx.slice.Slice +import androidx.slice.SliceProvider +import com.android.settingslib.spa.framework.common.EntrySliceData +import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +private const val TAG = "SpaSliceProvider" + +class SpaSliceProvider : SliceProvider(), Observer { + private fun getOrPutSliceData(sliceUri: Uri): EntrySliceData? { + if (!SpaEnvironmentFactory.isReady()) return null + return SpaEnvironmentFactory.instance.sliceDataRepository.getOrBuildSliceData(sliceUri) + } + + override fun onBindSlice(sliceUri: Uri): Slice? { + if (context == null) return null + Log.d(TAG, "onBindSlice: $sliceUri") + return getOrPutSliceData(sliceUri)?.value + } + + override fun onSlicePinned(sliceUri: Uri) { + Log.d(TAG, "onSlicePinned: $sliceUri") + super.onSlicePinned(sliceUri) + val sliceLiveData = getOrPutSliceData(sliceUri) ?: return + runBlocking { + withContext(Dispatchers.Main) { + sliceLiveData.observeForever(this@SpaSliceProvider) + } + } + } + + override fun onSliceUnpinned(sliceUri: Uri) { + Log.d(TAG, "onSliceUnpinned: $sliceUri") + super.onSliceUnpinned(sliceUri) + val sliceLiveData = getOrPutSliceData(sliceUri) ?: return + runBlocking { + withContext(Dispatchers.Main) { + sliceLiveData.removeObserver(this@SpaSliceProvider) + } + } + } + + override fun onChanged(slice: Slice?) { + val uri = slice?.uri ?: return + Log.d(TAG, "onChanged: $uri") + context?.contentResolver?.notifyChange(uri, null) + } + + override fun onCreateSliceProvider(): Boolean { + Log.d(TAG, "onCreateSliceProvider") + return true + } +} \ No newline at end of file diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/EntrySliceData.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/EntrySliceData.kt new file mode 100644 index 0000000000000..fc551a88f6c54 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/EntrySliceData.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2022 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.settingslib.spa.framework.common + +import androidx.lifecycle.LiveData +import androidx.slice.Slice +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +open class EntrySliceData : LiveData() { + private val asyncRunnerScope = CoroutineScope(Dispatchers.IO) + private var asyncRunnerJob: Job? = null + private var asyncActionJob: Job? = null + private var isActive = false + + open suspend fun asyncRunner() {} + + open suspend fun asyncAction() {} + + override fun onActive() { + asyncRunnerJob?.cancel() + asyncRunnerJob = asyncRunnerScope.launch { asyncRunner() } + isActive = true + } + + override fun onInactive() { + asyncRunnerJob?.cancel() + asyncRunnerJob = null + asyncActionJob?.cancel() + asyncActionJob = null + isActive = false + } + + fun isActive(): Boolean { + return isActive + } + + fun doAction() { + asyncActionJob?.cancel() + asyncActionJob = asyncRunnerScope.launch { asyncAction() } + } +} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SettingsEntry.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SettingsEntry.kt index 224fe1d03390c..9ee7f9ef7f86c 100644 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SettingsEntry.kt +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SettingsEntry.kt @@ -16,6 +16,7 @@ package com.android.settingslib.spa.framework.common +import android.net.Uri import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -39,6 +40,11 @@ interface EntryData { val LocalEntryDataProvider = compositionLocalOf { object : EntryData {} } +typealias UiLayerRenderer = @Composable (arguments: Bundle?) -> Unit +typealias StatusDataGetter = (arguments: Bundle?) -> EntryStatusData? +typealias SearchDataGetter = (arguments: Bundle?) -> EntrySearchData? +typealias SliceDataGetter = (sliceUri: Uri, arguments: Bundle?) -> EntrySliceData? + /** * Defines data of a Settings entry. */ @@ -71,7 +77,10 @@ data class SettingsEntry( // Indicate whether the status of entry is mutable. // If so, for instance, we'll reindex its status for search. - val mutableStatus: Boolean = false, + val hasMutableStatus: Boolean = false, + + // Indicate whether the entry has SliceProvider support. + val hasSliceSupport: Boolean = false, /** * ======================================== @@ -83,13 +92,19 @@ data class SettingsEntry( * API to get the status data of the entry, such as isDisabled / isSwitchOff. * Returns null if this entry do NOT have any status. */ - private val statusDataImpl: (arguments: Bundle?) -> EntryStatusData? = { null }, + private val statusDataImpl: StatusDataGetter = { null }, /** * API to get Search indexing data for this entry, such as title / keyword. * Returns null if this entry do NOT support search. */ - private val searchDataImpl: (arguments: Bundle?) -> EntrySearchData? = { null }, + private val searchDataImpl: SearchDataGetter = { null }, + + /** + * API to get Slice data of this entry. The Slice data is implemented as a LiveData, + * and is associated with the Slice's lifecycle (pin / unpin) by the framework. + */ + private val sliceDataImpl: SliceDataGetter = { _: Uri, _: Bundle? -> null }, /** * API to Render UI of this entry directly. For now, we use it in the internal injection, to @@ -97,7 +112,7 @@ data class SettingsEntry( * injected entry. In the long term, we may deprecate the @Composable Page() API in SPP, and * use each entries' UI rendering function in the page instead. */ - private val uiLayoutImpl: (@Composable (arguments: Bundle?) -> Unit) = {}, + private val uiLayoutImpl: UiLayerRenderer = {}, ) { fun containerPage(): SettingsPage { // The Container page of the entry, which is the from-page or @@ -121,6 +136,10 @@ data class SettingsEntry( return searchDataImpl(fullArgument(runtimeArguments)) } + fun getSliceData(sliceUri: Uri, runtimeArguments: Bundle? = null): EntrySliceData? { + return sliceDataImpl(sliceUri, fullArgument(runtimeArguments)) + } + @Composable fun UiLayout(runtimeArguments: Bundle? = null) { CompositionLocalProvider(provideLocalEntryData()) { @@ -152,12 +171,14 @@ class SettingsEntryBuilder(private val name: String, private val owner: Settings // Attributes private var isAllowSearch: Boolean = false private var isSearchDataDynamic: Boolean = false - private var mutableStatus: Boolean = false + private var hasMutableStatus: Boolean = false + private var hasSliceSupport: Boolean = false // Functions - private var statusDataFn: (arguments: Bundle?) -> EntryStatusData? = { null } - private var searchDataFn: (arguments: Bundle?) -> EntrySearchData? = { null } - private var uiLayoutFn: (@Composable (arguments: Bundle?) -> Unit) = { } + private var uiLayoutFn: UiLayerRenderer = { } + private var statusDataFn: StatusDataGetter = { null } + private var searchDataFn: SearchDataGetter = { null } + private var sliceDataFn: SliceDataGetter = { _: Uri, _: Bundle? -> null } fun build(): SettingsEntry { return SettingsEntry( @@ -173,11 +194,13 @@ class SettingsEntryBuilder(private val name: String, private val owner: Settings // attributes isAllowSearch = isAllowSearch, isSearchDataDynamic = isSearchDataDynamic, - mutableStatus = mutableStatus, + hasMutableStatus = hasMutableStatus, + hasSliceSupport = hasSliceSupport, // functions statusDataImpl = statusDataFn, searchDataImpl = searchDataFn, + sliceDataImpl = sliceDataFn, uiLayoutImpl = uiLayoutFn, ) } @@ -207,7 +230,7 @@ class SettingsEntryBuilder(private val name: String, private val owner: Settings } fun setHasMutableStatus(hasMutableStatus: Boolean): SettingsEntryBuilder { - this.mutableStatus = hasMutableStatus + this.hasMutableStatus = hasMutableStatus return this } @@ -221,17 +244,23 @@ class SettingsEntryBuilder(private val name: String, private val owner: Settings return this } - fun setStatusDataFn(fn: (arguments: Bundle?) -> EntryStatusData?): SettingsEntryBuilder { + fun setStatusDataFn(fn: StatusDataGetter): SettingsEntryBuilder { this.statusDataFn = fn return this } - fun setSearchDataFn(fn: (arguments: Bundle?) -> EntrySearchData?): SettingsEntryBuilder { + fun setSearchDataFn(fn: SearchDataGetter): SettingsEntryBuilder { this.searchDataFn = fn return this } - fun setUiLayoutFn(fn: @Composable (arguments: Bundle?) -> Unit): SettingsEntryBuilder { + fun setSliceDataFn(fn: SliceDataGetter): SettingsEntryBuilder { + this.sliceDataFn = fn + this.hasSliceSupport = true + return this + } + + fun setUiLayoutFn(fn: UiLayerRenderer): SettingsEntryBuilder { this.uiLayoutFn = fn return this } diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SettingsPage.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SettingsPage.kt index bb287d1b927a2..a372bbd3721bc 100644 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SettingsPage.kt +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SettingsPage.kt @@ -113,6 +113,12 @@ data class SettingsPage( ) } + fun createBrowseIntent(entryId: String? = null): Intent? { + val context = SpaEnvironmentFactory.instance.appContext + val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass + return createBrowseIntent(context, browseActivityClass, entryId) + } + fun createBrowseIntent( context: Context?, browseActivityClass: Class?, diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SpaEnvironment.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SpaEnvironment.kt index a9cb041cc1986..9bb06f02c1b68 100644 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SpaEnvironment.kt +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/common/SpaEnvironment.kt @@ -17,10 +17,12 @@ package com.android.settingslib.spa.framework.common import android.app.Activity +import android.content.BroadcastReceiver import android.content.Context import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import com.android.settingslib.spa.slice.SettingsSliceDataRepository private const val TAG = "SpaEnvironment" @@ -46,6 +48,10 @@ object SpaEnvironmentFactory { Log.d(TAG, "resetForPreview") } + fun isReady(): Boolean { + return spaEnvironment != null + } + val instance: SpaEnvironment get() { if (spaEnvironment == null) @@ -59,13 +65,15 @@ abstract class SpaEnvironment(context: Context) { val entryRepository = lazy { SettingsEntryRepository(pageProviderRepository.value) } + val sliceDataRepository = SettingsSliceDataRepository() + // In Robolectric test, applicationContext is not available. Use context as fallback. val appContext: Context = context.applicationContext ?: context open val browseActivityClass: Class? = null - + open val sliceBroadcastReceiverClass: Class? = null open val searchProviderAuthorities: String? = null - + open val sliceProviderAuthorities: String? = null open val logger: SpaLogger = object : SpaLogger {} // TODO: add other environment setup here. diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/debug/DebugActivity.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/debug/DebugActivity.kt index 26491d51e838d..760064a7d691a 100644 --- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/debug/DebugActivity.kt +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/debug/DebugActivity.kt @@ -16,6 +16,7 @@ package com.android.settingslib.spa.framework.debug +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -38,6 +39,8 @@ import com.android.settingslib.spa.framework.compose.localNavController import com.android.settingslib.spa.framework.compose.navigator import com.android.settingslib.spa.framework.compose.toState import com.android.settingslib.spa.framework.theme.SettingsTheme +import com.android.settingslib.spa.slice.appendSliceParams +import com.android.settingslib.spa.slice.presenter.SliceDemo import com.android.settingslib.spa.widget.preference.Preference import com.android.settingslib.spa.widget.preference.PreferenceModel import com.android.settingslib.spa.widget.scaffold.HomeScaffold @@ -47,6 +50,7 @@ private const val TAG = "DebugActivity" private const val ROUTE_ROOT = "root" private const val ROUTE_All_PAGES = "pages" private const val ROUTE_All_ENTRIES = "entries" +private const val ROUTE_All_SLICES = "slices" private const val ROUTE_PAGE = "page" private const val ROUTE_ENTRY = "entry" private const val PARAM_NAME_PAGE_ID = "pid" @@ -81,6 +85,7 @@ class DebugActivity : ComponentActivity() { composable(route = ROUTE_ROOT) { RootPage() } composable(route = ROUTE_All_PAGES) { AllPages() } composable(route = ROUTE_All_ENTRIES) { AllEntries() } + composable(route = ROUTE_All_SLICES) { AllSlices() } composable( route = "$ROUTE_PAGE/{$PARAM_NAME_PAGE_ID}", arguments = listOf( @@ -102,6 +107,8 @@ class DebugActivity : ComponentActivity() { val entryRepository by spaEnvironment.entryRepository val allPageWithEntry = remember { entryRepository.getAllPageWithEntry() } val allEntry = remember { entryRepository.getAllEntries() } + val allSliceEntry = + remember { entryRepository.getAllEntries().filter { it.hasSliceSupport } } HomeScaffold(title = "Settings Debug") { Preference(object : PreferenceModel { override val title = "List All Pages (${allPageWithEntry.size})" @@ -111,6 +118,10 @@ class DebugActivity : ComponentActivity() { override val title = "List All Entries (${allEntry.size})" override val onClick = navigator(route = ROUTE_All_ENTRIES) }) + Preference(object : PreferenceModel { + override val title = "List All Slices (${allSliceEntry.size})" + override val onClick = navigator(route = ROUTE_All_SLICES) + }) } } @@ -139,6 +150,19 @@ class DebugActivity : ComponentActivity() { } } + @Composable + fun AllSlices() { + val entryRepository by spaEnvironment.entryRepository + val authority = spaEnvironment.sliceProviderAuthorities + val allSliceEntry = + remember { entryRepository.getAllEntries().filter { it.hasSliceSupport } } + RegularScaffold(title = "All Slices (${allSliceEntry.size})") { + for (entry in allSliceEntry) { + SliceDemo(sliceUri = entry.createSliceUri(authority)) + } + } + } + @Composable fun OnePage(arguments: Bundle?) { val context = LocalContext.current @@ -221,6 +245,18 @@ class DebugActivity : ComponentActivity() { } } +private fun SettingsEntry.createSliceUri( + authority: String?, + runtimeArguments: Bundle? = null +): Uri { + if (authority == null) return Uri.EMPTY + return Uri.Builder().scheme("content").authority(authority).appendSliceParams( + route = this.containerPage().buildRoute(), + entryId = this.id, + runtimeArguments = runtimeArguments, + ).build() +} + /** * A blank activity without any page. */ diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/SettingsSliceDataRepository.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/SettingsSliceDataRepository.kt new file mode 100644 index 0000000000000..d8d23788c204b --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/SettingsSliceDataRepository.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 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.settingslib.spa.slice + +import android.net.Uri +import android.util.Log +import com.android.settingslib.spa.framework.common.EntrySliceData +import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory + +private const val TAG = "SliceDataRepository" + +class SettingsSliceDataRepository { + // The map of slice uri to its EntrySliceData, a.k.a. LiveData + private val sliceDataMap: MutableMap = mutableMapOf() + + // Note: mark this function synchronized, so that we can get the same livedata during the + // whole lifecycle of a Slice. + @Synchronized + fun getOrBuildSliceData(sliceUri: Uri): EntrySliceData? { + val sliceString = sliceUri.getSliceId() ?: return null + return sliceDataMap[sliceString] ?: buildLiveDataImpl(sliceUri)?.let { + sliceDataMap[sliceString] = it + it + } + } + + fun getActiveSliceData(sliceUri: Uri): EntrySliceData? { + val sliceString = sliceUri.getSliceId() ?: return null + val sliceData = sliceDataMap[sliceString] ?: return null + return if (sliceData.isActive()) sliceData else null + } + + private fun buildLiveDataImpl(sliceUri: Uri): EntrySliceData? { + Log.d(TAG, "buildLiveData: $sliceUri") + if (!SpaEnvironmentFactory.isReady()) return null + + val entryRepository by SpaEnvironmentFactory.instance.entryRepository + val entryId = sliceUri.getEntryId() ?: return null + val entry = entryRepository.getEntry(entryId) ?: return null + if (!entry.hasSliceSupport) return null + + val arguments = sliceUri.getRuntimeArguments() + return entry.getSliceData(runtimeArguments = arguments, sliceUri = sliceUri) + } +} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/SliceUtil.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/SliceUtil.kt new file mode 100644 index 0000000000000..ff143ed864c81 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/SliceUtil.kt @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2022 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.settingslib.spa.slice + +import android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import com.android.settingslib.spa.framework.BrowseActivity.Companion.KEY_DESTINATION +import com.android.settingslib.spa.framework.BrowseActivity.Companion.KEY_HIGHLIGHT_ENTRY +import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory + +// Defines SliceUri, which contains special query parameters: +// -- KEY_DESTINATION: The route that this slice is navigated to. +// -- KEY_HIGHLIGHT_ENTRY: The entry id of this slice +// Other parameters can considered as runtime parameters. +// Use {entryId, runtimeParams} as the unique Id of this Slice. +typealias SliceUri = Uri + +val RESERVED_KEYS = listOf( + KEY_DESTINATION, + KEY_HIGHLIGHT_ENTRY +) + +fun SliceUri.getEntryId(): String? { + return getQueryParameter(KEY_HIGHLIGHT_ENTRY) +} + +fun SliceUri.getDestination(): String? { + return getQueryParameter(KEY_DESTINATION) +} + +fun SliceUri.getRuntimeArguments(): Bundle { + val params = Bundle() + for (queryName in queryParameterNames) { + if (RESERVED_KEYS.contains(queryName)) continue + params.putString(queryName, getQueryParameter(queryName)) + } + return params +} + +fun SliceUri.getSliceId(): String? { + val entryId = getEntryId() ?: return null + val params = getRuntimeArguments() + return "${entryId}_$params" +} + +fun Uri.Builder.appendSliceParams( + route: String? = null, + entryId: String? = null, + runtimeArguments: Bundle? = null +): Uri.Builder { + if (route != null) appendQueryParameter(KEY_DESTINATION, route) + if (entryId != null) appendQueryParameter(KEY_HIGHLIGHT_ENTRY, entryId) + if (runtimeArguments != null) { + for (key in runtimeArguments.keySet()) { + appendQueryParameter(key, runtimeArguments.getString(key, "")) + } + } + return this +} + +fun SliceUri.createBroadcastPendingIntent(): PendingIntent? { + val context = SpaEnvironmentFactory.instance.appContext + val sliceBroadcastClass = + SpaEnvironmentFactory.instance.sliceBroadcastReceiverClass ?: return null + val entryId = getEntryId() ?: return null + return createBroadcastPendingIntent(context, sliceBroadcastClass, entryId) +} + +fun SliceUri.createBrowsePendingIntent(): PendingIntent? { + val context = SpaEnvironmentFactory.instance.appContext + val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass ?: return null + val destination = getDestination() ?: return null + val entryId = getEntryId() + return createBrowsePendingIntent(context, browseActivityClass, destination, entryId) +} + +fun Intent.createBrowsePendingIntent(): PendingIntent? { + val context = SpaEnvironmentFactory.instance.appContext + val browseActivityClass = SpaEnvironmentFactory.instance.browseActivityClass ?: return null + val destination = getStringExtra(KEY_DESTINATION) ?: return null + val entryId = getStringExtra(KEY_HIGHLIGHT_ENTRY) + return createBrowsePendingIntent(context, browseActivityClass, destination, entryId) +} + +private fun createBrowsePendingIntent( + context: Context, + browseActivityClass: Class, + destination: String, + entryId: String? +): PendingIntent { + val intent = Intent().setComponent(ComponentName(context, browseActivityClass)) + .apply { + // Set both extra and data (which is a Uri) in Slice Intent: + // 1) extra is used in SPA navigation framework + // 2) data is used in Slice framework + putExtra(KEY_DESTINATION, destination) + if (entryId != null) { + putExtra(KEY_HIGHLIGHT_ENTRY, entryId) + } + data = Uri.Builder().appendSliceParams(destination, entryId).build() + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) +} + +private fun createBroadcastPendingIntent( + context: Context, + sliceBroadcastClass: Class, + entryId: String +): PendingIntent { + val intent = Intent().setComponent(ComponentName(context, sliceBroadcastClass)) + .apply { data = Uri.Builder().appendSliceParams(entryId = entryId).build() } + return PendingIntent.getBroadcast( + context, 0 /* requestCode */, intent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE + ) +} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/presenter/Demo.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/presenter/Demo.kt new file mode 100644 index 0000000000000..cff1c0c619c42 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/presenter/Demo.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2022 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.settingslib.spa.slice.presenter + +import android.net.Uri +import androidx.compose.material3.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.slice.widget.SliceLiveData +import androidx.slice.widget.SliceView + +@Composable +fun SliceDemo(sliceUri: Uri) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val sliceData = remember { + SliceLiveData.fromUri(context, sliceUri) + } + + Divider() + AndroidView( + factory = { localContext -> + val view = SliceView(localContext) + view.setShowTitleItems(true) + view.isScrollable = false + view + }, + update = { view -> sliceData.observe(lifecycleOwner, view) } + ) +} diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/provider/Demo.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/provider/Demo.kt new file mode 100644 index 0000000000000..b65b91f7990e1 --- /dev/null +++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/slice/provider/Demo.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2022 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.settingslib.spa.slice.provider + +import android.app.PendingIntent +import android.content.Context +import android.net.Uri +import androidx.core.graphics.drawable.IconCompat +import androidx.slice.Slice +import androidx.slice.SliceManager +import androidx.slice.builders.ListBuilder +import androidx.slice.builders.SliceAction +import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory +import com.android.settingslib.spa.slice.createBroadcastPendingIntent +import com.android.settingslib.spa.slice.createBrowsePendingIntent + +fun createDemoBrowseSlice(sliceUri: Uri, title: String, summary: String): Slice? { + val intent = sliceUri.createBrowsePendingIntent() ?: return null + return createDemoSlice(sliceUri, title, summary, intent) +} + +fun createDemoActionSlice(sliceUri: Uri, title: String, summary: String): Slice? { + val intent = sliceUri.createBroadcastPendingIntent() ?: return null + return createDemoSlice(sliceUri, title, summary, intent) +} + +fun createDemoSlice(sliceUri: Uri, title: String, summary: String, intent: PendingIntent): Slice? { + val context = SpaEnvironmentFactory.instance.appContext + if (!SliceManager.getInstance(context).pinnedSlices.contains(sliceUri)) return null + return ListBuilder(context, sliceUri, ListBuilder.INFINITY) + .addRow(ListBuilder.RowBuilder().apply { + setPrimaryAction(createSliceAction(context, intent)) + setTitle(title) + setSubtitle(summary) + }).build() +} + +private fun createSliceAction(context: Context, intent: PendingIntent): SliceAction { + return SliceAction.create( + intent, + IconCompat.createWithResource( + context, + com.google.android.material.R.drawable.navigation_empty_icon + ), + ListBuilder.ICON_IMAGE, + "Enter app" + ) +} diff --git a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/common/SettingsEntryTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/common/SettingsEntryTest.kt index 31d2ae46fad7d..2017d538ae819 100644 --- a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/common/SettingsEntryTest.kt +++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/common/SettingsEntryTest.kt @@ -63,7 +63,7 @@ class SettingsEntryTest { assertThat(entry.toPage).isNull() assertThat(entry.isAllowSearch).isFalse() assertThat(entry.isSearchDataDynamic).isFalse() - assertThat(entry.mutableStatus).isFalse() + assertThat(entry.hasMutableStatus).isFalse() } @Test @@ -133,7 +133,7 @@ class SettingsEntryTest { assertThat(entry.toPage).isNull() assertThat(entry.isAllowSearch).isTrue() assertThat(entry.isSearchDataDynamic).isFalse() - assertThat(entry.mutableStatus).isTrue() + assertThat(entry.hasMutableStatus).isTrue() } @Test diff --git a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/common/SettingsPageTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/common/SettingsPageTest.kt index 539e56b1e134e..7097a5da4268e 100644 --- a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/common/SettingsPageTest.kt +++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/common/SettingsPageTest.kt @@ -118,6 +118,7 @@ class SettingsPageTest { page.enterPage() page.leavePage() page.enterPage() + assertThat(page.createBrowseIntent()).isNotNull() assertThat(spaLogger.getEventCount(page.id, LogEvent.PAGE_ENTER, LogCategory.FRAMEWORK)) .isEqualTo(2) assertThat(spaLogger.getEventCount(page.id, LogEvent.PAGE_LEAVE, LogCategory.FRAMEWORK))