Merge "Support slice in Spa."
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -56,6 +56,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Slice?> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Slice?>() {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -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<EntryData> { 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
|
||||
}
|
||||
|
||||
@@ -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<out Activity>?,
|
||||
|
||||
@@ -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<out Activity>? = null
|
||||
|
||||
open val sliceBroadcastReceiverClass: Class<out BroadcastReceiver>? = null
|
||||
open val searchProviderAuthorities: String? = null
|
||||
|
||||
open val sliceProviderAuthorities: String? = null
|
||||
open val logger: SpaLogger = object : SpaLogger {}
|
||||
|
||||
// TODO: add other environment setup here.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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<Slice?>
|
||||
private val sliceDataMap: MutableMap<String, EntrySliceData> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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<out Activity>,
|
||||
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<out BroadcastReceiver>,
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user