diff --git a/app/src/main/java/com/aurora/Constants.kt b/app/src/main/java/com/aurora/Constants.kt index c5ab466cd..7945f2034 100644 --- a/app/src/main/java/com/aurora/Constants.kt +++ b/app/src/main/java/com/aurora/Constants.kt @@ -62,6 +62,8 @@ object Constants { const val TOP_CHART_CATEGORY = "TOP_CHART_CATEGORY" const val JSON_MIME_TYPE = "application/json" + const val PROPERTIES_IMPORT_MIME_TYPE = "application/octet-stream" + const val PROPERTIES_EXPORT_MIME_TYPE = "text/x-java-properties" // PACKAGE NAMES const val PACKAGE_NAME_GMS = "com.google.android.gms" diff --git a/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt index 0612714ac..7bd6e2325 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt @@ -5,6 +5,10 @@ package com.aurora.store.compose.ui.spoof +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -27,17 +31,29 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.util.fastForEachIndexed +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.aurora.Constants +import com.aurora.extensions.toast import com.aurora.store.R import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.ui.spoof.menu.MenuItem +import com.aurora.store.compose.ui.spoof.menu.SpoofMenu import com.aurora.store.compose.ui.spoof.navigation.SpoofPage import com.aurora.store.data.providers.AccountProvider +import com.aurora.store.viewmodel.spoof.SpoofViewModel import kotlinx.coroutines.launch @Composable -fun SpoofScreen(onNavigateUp: () -> Unit, onNavigateToSplash: () -> Unit) { +fun SpoofScreen( + onNavigateUp: () -> Unit, + onNavigateToSplash: () -> Unit, + viewModel: SpoofViewModel = hiltViewModel(), +) { ScreenContent( onNavigateUp = onNavigateUp, - onNavigateToSplash = onNavigateToSplash + onNavigateToSplash = onNavigateToSplash, + onDeviceSpoofImport = { uri -> viewModel.importDeviceSpoof(uri) }, + onDeviceSpoofExport = { uri -> viewModel.exportDeviceSpoof(uri) } ) } @@ -45,13 +61,36 @@ fun SpoofScreen(onNavigateUp: () -> Unit, onNavigateToSplash: () -> Unit) { private fun ScreenContent( pages: List = listOf(SpoofPage.DEVICE, SpoofPage.LOCALE), onNavigateUp: () -> Unit = {}, - onNavigateToSplash: () -> Unit = {} + onNavigateToSplash: () -> Unit = {}, + onDeviceSpoofImport: (uri: Uri) -> Unit = {}, + onDeviceSpoofExport: (uri: Uri) -> Unit = {} ) { val context = LocalContext.current val pagerState = rememberPagerState { pages.size } val coroutineScope = rememberCoroutineScope() val snackBarHostState = remember { SnackbarHostState() } + val docImportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { + if (it != null) { + onDeviceSpoofImport(it) + } else { + context.toast(R.string.toast_import_failed) + } + } + ) + val docExportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(Constants.PROPERTIES_EXPORT_MIME_TYPE), + onResult = { + if (it != null) { + onDeviceSpoofExport(it) + } else { + context.toast(R.string.toast_export_failed) + } + } + ) + fun onRequestNavigateToSplash() { coroutineScope.launch { val result = snackBarHostState.showSnackbar( @@ -64,11 +103,29 @@ private fun ScreenContent( AccountProvider.logout(context) onNavigateToSplash() } + else -> Unit } } } + @Composable + fun SetupMenu() { + SpoofMenu { menuItem -> + when (menuItem) { + MenuItem.IMPORT -> { + docImportLauncher.launch(arrayOf(Constants.PROPERTIES_IMPORT_MIME_TYPE)) + } + + MenuItem.EXPORT -> { + docExportLauncher.launch( + "aurora_store_${Build.BRAND}_${Build.DEVICE}.properties" + ) + } + } + } + } + Scaffold( snackbarHost = { SnackbarHost(hostState = snackBarHostState) @@ -76,7 +133,8 @@ private fun ScreenContent( topBar = { TopAppBar( title = stringResource(R.string.title_spoof_manager), - onNavigateUp = onNavigateUp + onNavigateUp = onNavigateUp, + actions = { SetupMenu() } ) } ) { paddingValues -> diff --git a/app/src/main/java/com/aurora/store/compose/ui/spoof/menu/MenuItem.kt b/app/src/main/java/com/aurora/store/compose/ui/spoof/menu/MenuItem.kt new file mode 100644 index 000000000..313817774 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/spoof/menu/MenuItem.kt @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.spoof.menu + +enum class MenuItem { + IMPORT, + EXPORT +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/spoof/menu/SpoofMenu.kt b/app/src/main/java/com/aurora/store/compose/ui/spoof/menu/SpoofMenu.kt new file mode 100644 index 000000000..0a2753b67 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/spoof/menu/SpoofMenu.kt @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.spoof.menu + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.aurora.store.R +import com.aurora.store.compose.preview.PreviewTemplate + +/** + * Menu for the blacklist screen + * @param modifier The modifier to be applied to the composable + * @param onMenuItemClicked Callback when a menu item has been clicked + * @see MenuItem + */ +@Composable +fun SpoofMenu( + modifier: Modifier = Modifier, + isExpanded: Boolean = false, + onMenuItemClicked: (menuItem: MenuItem) -> Unit = {} +) { + var expanded by remember { mutableStateOf(isExpanded) } + fun onClick(menuItem: MenuItem) { + onMenuItemClicked(menuItem) + expanded = false + } + + Box(modifier = modifier) { + IconButton(onClick = { expanded = true }) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = stringResource(R.string.menu) + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_import)) }, + onClick = { onClick(MenuItem.IMPORT) } + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.action_export)) }, + onClick = { onClick(MenuItem.EXPORT) } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SpoofMenuPreview() { + PreviewTemplate { + SpoofMenu(isExpanded = true) + } +} diff --git a/app/src/main/java/com/aurora/store/viewmodel/spoof/SpoofViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/spoof/SpoofViewModel.kt index cde38a242..f54e5e661 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/spoof/SpoofViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/spoof/SpoofViewModel.kt @@ -1,9 +1,12 @@ package com.aurora.store.viewmodel.spoof import android.content.Context +import android.net.Uri +import android.util.Log import androidx.lifecycle.ViewModel import com.aurora.store.data.providers.NativeDeviceInfoProvider import com.aurora.store.data.providers.SpoofProvider +import com.aurora.store.util.PathUtil import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -14,9 +17,11 @@ import javax.inject.Inject @HiltViewModel class SpoofViewModel @Inject constructor( - val spoofProvider: SpoofProvider, + private val spoofProvider: SpoofProvider, @ApplicationContext private val context: Context -): ViewModel() { +) : ViewModel() { + + private val TAG = SpoofViewModel::class.java.simpleName val defaultLocale: Locale = Locale.getDefault() val defaultProperties = NativeDeviceInfoProvider.getNativeDeviceProperties(context) @@ -52,4 +57,26 @@ class SpoofViewModel @Inject constructor( spoofProvider.setSpoofLocale(locale) } } + + fun importDeviceSpoof(uri: Uri) { + try { + context.contentResolver?.openInputStream(uri)?.use { input -> + PathUtil.getNewEmptySpoofConfig(context).outputStream().use { + input.copyTo(it) + } + } + _availableDevices.value = spoofProvider.availableDeviceProperties + } catch (exception: Exception) { + Log.e(TAG, "Failed to import device config", exception) + } + } + + fun exportDeviceSpoof(uri: Uri) { + try { + NativeDeviceInfoProvider.getNativeDeviceProperties(context, true) + .store(context.contentResolver?.openOutputStream(uri), "DEVICE_CONFIG") + } catch (exception: Exception) { + Log.e(TAG, "Failed to export device config", exception) + } + } } diff --git a/app/src/main/res/layout/fragment_spoof.xml b/app/src/main/res/layout/fragment_spoof.xml deleted file mode 100644 index 70d3b864d..000000000 --- a/app/src/main/res/layout/fragment_spoof.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - -