From 9e1d18b8e75ff0b114d991c1e5b370b09b774a26 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Sun, 12 Oct 2025 13:16:50 +0800 Subject: [PATCH] ViewModel refactoring: Settings part User restriction shortcuts Optimize ShortcutUtils Fix Private DNS bug --- .../com/bintianqi/owndroid/ApiReceiver.kt | 7 +- .../com/bintianqi/owndroid/MainActivity.kt | 30 +- .../com/bintianqi/owndroid/MyApplication.kt | 1 + .../com/bintianqi/owndroid/MyViewModel.kt | 61 +++- .../bintianqi/owndroid/NotificationUtils.kt | 74 +++-- .../java/com/bintianqi/owndroid/Receiver.kt | 62 ++-- .../java/com/bintianqi/owndroid/Settings.kt | 264 +++++++++--------- .../com/bintianqi/owndroid/SharedPrefs.kt | 5 +- .../com/bintianqi/owndroid/ShortcutUtils.kt | 46 ++- .../owndroid/ShortcutsReceiverActivity.kt | 17 +- .../owndroid/UserRestrictionsRepository.kt | 7 + .../main/java/com/bintianqi/owndroid/Utils.kt | 12 +- .../java/com/bintianqi/owndroid/dpm/DPM.kt | 6 +- .../com/bintianqi/owndroid/dpm/Network.kt | 18 +- .../com/bintianqi/owndroid/dpm/Password.kt | 18 +- .../java/com/bintianqi/owndroid/dpm/System.kt | 10 +- .../bintianqi/owndroid/dpm/UserRestriction.kt | 26 +- .../java/com/bintianqi/owndroid/dpm/Users.kt | 4 +- app/src/main/res/values-ru/strings.xml | 4 - app/src/main/res/values-tr/strings.xml | 4 - app/src/main/res/values-zh-rCN/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- 22 files changed, 420 insertions(+), 266 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt index 58b0699..550df66 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ApiReceiver.kt @@ -9,10 +9,9 @@ import android.util.Log class ApiReceiver: BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val requestKey = intent.getStringExtra("key") - var log = "OwnDroid API request received. action: ${intent.action}\nkey: $requestKey" - if(!SP.isApiEnabled) return - val key = SP.apiKey - if(!key.isNullOrEmpty() && key == requestKey) { + var log = "OwnDroid API request received. action: ${intent.action}" + val key = SP.apiKeyHash + if(!key.isNullOrEmpty() && key == requestKey?.hash()) { val app = intent.getStringExtra("package") val permission = intent.getStringExtra("permission") val restriction = intent.getStringExtra("restriction") diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index b060f42..9e89f07 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -1,9 +1,11 @@ package com.bintianqi.owndroid +import android.Manifest import android.os.Build.VERSION import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -240,6 +242,10 @@ class MainActivity : FragmentActivity() { val locale = context.resources?.configuration?.locale zhCN = locale == Locale.SIMPLIFIED_CHINESE || locale == Locale.CHINESE || locale == Locale.CHINA val vm by viewModels() + if (VERSION.SDK_INT >= 33) { + val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } setContent { var appLockDialog by rememberSaveable { mutableStateOf(false) } val theme by vm.theme.collectAsStateWithLifecycle() @@ -576,7 +582,7 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { } composable { UserRestrictionOptionsScreen(it.toRoute(), vm.userRestrictions, - vm::setUserRestriction, ::navigateUp) + vm::setUserRestriction, vm::createUserRestrictionShortcut, ::navigateUp) } composable { UsersScreen(vm, ::navigateUp, ::navigate) } @@ -619,14 +625,22 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { composable { RequiredPasswordQualityScreen(::navigateUp) } composable { SettingsScreen(::navigateUp, ::navigate) } - composable { SettingsOptionsScreen(::navigateUp) } - composable { - val theme by vm.theme.collectAsStateWithLifecycle() - AppearanceScreen(::navigateUp, theme, vm::changeTheme) + composable { + SettingsOptionsScreen(vm::getDisplayDangerousFeatures, vm::getShortcutsEnabled, + vm::setDisplayDangerousFeatures, vm::setShortcutsEnabled, ::navigateUp) + } + composable { + AppearanceScreen(::navigateUp, vm.theme, vm::changeTheme) + } + composable { + AppLockSettingsScreen(vm::getAppLockConfig, vm::setAppLockConfig, ::navigateUp) + } + composable { + ApiSettings(vm::getApiEnabled, vm::setApiKey, ::navigateUp) + } + composable { + NotificationsScreen(vm::getEnabledNotifications, vm::setNotificationEnabled, ::navigateUp) } - composable { AppLockSettingsScreen(::navigateUp) } - composable { ApiSettings(::navigateUp) } - composable { NotificationsScreen(::navigateUp) } composable { AboutScreen(::navigateUp) } } DisposableEffect(lifecycleOwner) { diff --git a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt index 9093ec7..d6105c3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyApplication.kt @@ -13,6 +13,7 @@ class MyApplication : Application() { val dbHelper = MyDbHelper(this) myRepo = MyRepository(dbHelper) Privilege.initialize(applicationContext) + NotificationUtils.createChannels(this) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 3ab7410..dcea517 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -122,6 +122,7 @@ import kotlin.reflect.jvm.jvmErasure class MyViewModel(application: Application): AndroidViewModel(application) { val myRepo = getApplication().myRepo val PM = application.packageManager + val theme = MutableStateFlow(ThemeSettings(SP.materialYou, SP.darkTheme, SP.blackTheme)) fun changeTheme(newTheme: ThemeSettings) { theme.value = newTheme @@ -129,6 +130,54 @@ class MyViewModel(application: Application): AndroidViewModel(application) { SP.darkTheme = newTheme.darkTheme SP.blackTheme = newTheme.blackTheme } + fun getDisplayDangerousFeatures(): Boolean { + return SP.displayDangerousFeatures + } + fun getShortcutsEnabled(): Boolean { + return SP.shortcuts + } + fun setDisplayDangerousFeatures(state: Boolean) { + SP.displayDangerousFeatures = state + } + fun setShortcutsEnabled(enabled: Boolean) { + SP.shortcuts = enabled + ShortcutUtils.setAllShortcuts(application, enabled) + } + fun getAppLockConfig(): AppLockConfig { + val passwordHash = SP.lockPasswordHash + return AppLockConfig(passwordHash?.ifEmpty { null }, SP.biometricsUnlock, SP.lockWhenLeaving) + } + fun setAppLockConfig(config: AppLockConfig) { + SP.lockPasswordHash = if (config.password == null) { + "" + } else { + config.password.hash() + } + SP.biometricsUnlock = config.biometrics + SP.lockWhenLeaving = config.whenLeaving + } + fun getApiEnabled(): Boolean { + return SP.apiKeyHash?.isNotEmpty() ?: false + } + fun setApiKey(key: String) { + SP.apiKeyHash = if (key.isEmpty()) "" else key.hash() + } + fun getEnabledNotifications(): List { + val list = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } + return if (list == null) { + NotificationType.entries + } else { + NotificationType.entries.filter { it.id in list } + } + } + fun setNotificationEnabled(type: NotificationType, enabled: Boolean) { + val list = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } + SP.notifications = if (list == null) { + NotificationType.entries.minus(type).map { it.id } + } else { + list.run { if (enabled) plus(type.id) else minus(type.id) } + }.joinToString { it.toString() } + } val chosenPackage = Channel(1, BufferOverflow.DROP_LATEST) @@ -1083,11 +1132,17 @@ class MyViewModel(application: Application): AndroidViewModel(application) { DPM.clearUserRestriction(DAR, name) } userRestrictions.update { it.plus(name to state) } + ShortcutUtils.updateUserRestrictionShortcut(application, name, !state, true) true } catch (_: SecurityException) { false } } + fun createUserRestrictionShortcut(id: String): Boolean { + return ShortcutUtils.setUserRestrictionShortcut( + application, id, userRestrictions.value[id] ?: true + ) + } fun createWorkProfile(options: CreateWorkProfileOptions): Intent { val intent = Intent(DevicePolicyManager.ACTION_PROVISION_MANAGED_PROFILE) if (VERSION.SDK_INT >= 23) { @@ -1478,7 +1533,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun getPrivateDns(): PrivateDnsConfiguration { val mode = DPM.getGlobalPrivateDnsMode(DAR) return PrivateDnsConfiguration( - PrivateDnsMode.entries.find { it.id == mode }!!, DPM.getGlobalPrivateDnsHost(DAR) ?: "" + PrivateDnsMode.entries.find { it.id == mode }, DPM.getGlobalPrivateDnsHost(DAR) ?: "" ) } @Suppress("PrivateApi") @@ -1489,7 +1544,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { field.isAccessible = true val dpm = field.get(DPM) as IDevicePolicyManager val host = if (conf.mode == PrivateDnsMode.Host) conf.host else null - val result = dpm.setGlobalPrivateDns(DAR, conf.mode.id, host) + val result = dpm.setGlobalPrivateDns(DAR, conf.mode!!.id, host) result == DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR } catch (e: Exception) { e.printStackTrace() @@ -1669,7 +1724,7 @@ class MyViewModel(application: Application): AndroidViewModel(application) { fun getRpTokenState(): RpTokenState { return try { RpTokenState(true, DPM.isResetPasswordTokenActive(DAR)) - } catch (_: IllegalArgumentException) { + } catch (_: IllegalStateException) { RpTokenState(false, false) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt index 9cc55d8..0ed50a6 100644 --- a/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/NotificationUtils.kt @@ -7,6 +7,7 @@ import android.app.NotificationManager import android.content.Context import android.content.pm.PackageManager import android.os.Build +import androidx.core.app.NotificationCompat object NotificationUtils { fun checkPermission(context: Context): Boolean { @@ -14,36 +15,57 @@ object NotificationUtils { context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED else false } - fun registerChannels(context: Context) { - if(Build.VERSION.SDK_INT < 26) return + fun createChannels(context: Context) { + if (Build.VERSION.SDK_INT < 26) return val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val lockTaskMode = NotificationChannel(Channel.LOCK_TASK_MODE, context.getString(R.string.lock_task_mode), NotificationManager.IMPORTANCE_HIGH) - val events = NotificationChannel(Channel.EVENTS, context.getString(R.string.events), NotificationManager.IMPORTANCE_HIGH) + val lockTaskMode = NotificationChannel( + MyNotificationChannel.LockTaskMode.id, + context.getString(MyNotificationChannel.LockTaskMode.text), + NotificationManager.IMPORTANCE_HIGH + ) + val events = NotificationChannel( + MyNotificationChannel.Events.id, + context.getString(MyNotificationChannel.Events.text), + NotificationManager.IMPORTANCE_HIGH + ) nm.createNotificationChannels(listOf(lockTaskMode, events)) } - fun notify(context: Context, id: Int, notification: Notification) { - val sp = context.getSharedPreferences("data", Context.MODE_PRIVATE) - if(sp.getBoolean("n_$id", true) && checkPermission(context)) { - registerChannels(context) + fun notifyEvent(context: Context, type: NotificationType, text: String) { + val notification = NotificationCompat.Builder(context, MyNotificationChannel.Events.id) + .setSmallIcon(type.icon) + .setContentTitle(context.getString(type.text)) + .setContentText(text) + .build() + notify(context, type, notification) + } + fun notify(context: Context, type: NotificationType, notification: Notification) { + val enabledNotifications = SP.notifications?.split(',')?.mapNotNull { it.toIntOrNull() } + if (enabledNotifications == null || type.id in enabledNotifications) { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.notify(id, notification) + nm.notify(type.id, notification) } } - object Channel { - const val LOCK_TASK_MODE = "LockTaskMode" - const val EVENTS = "Events" + fun cancel(context: Context, type: NotificationType) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.cancel(type.id) } - object ID { - const val LOCK_TASK_MODE = 1 - const val PASSWORD_CHANGED = 2 - const val USER_ADDED = 3 - const val USER_STARTED = 4 - const val USER_SWITCHED = 5 - const val USER_STOPPED = 6 - const val USER_REMOVED = 7 - const val BUG_REPORT_SHARED = 8 - const val BUG_REPORT_SHARING_DECLINED = 9 - const val BUG_REPORT_FAILED = 10 - const val SYSTEM_UPDATE_PENDING = 11 - } -} \ No newline at end of file +} + +enum class NotificationType(val id: Int, val text: Int, val icon: Int) { + LockTaskMode(1, R.string.lock_task_mode, R.drawable.lock_fill0), + PasswordChanged(2, R.string.password_changed, R.drawable.password_fill0), + UserAdded(3, R.string.user_added, R.drawable.person_add_fill0), + UserStarted(4, R.string.user_started, R.drawable.person_fill0), + UserSwitched(5, R.string.user_switched, R.drawable.person_fill0), + UserStopped(6, R.string.user_stopped, R.drawable.person_off), + UserRemoved(7, R.string.user_removed, R.drawable.person_remove_fill0), + BugReportShared(8, R.string.bug_report_shared, R.drawable.bug_report_fill0), + BugReportSharingDeclined(9, R.string.bug_report_sharing_declined, R.drawable.bug_report_fill0), + BugReportFailed(10, R.string.bug_report_failed, R.drawable.bug_report_fill0), + SystemUpdatePending(11, R.string.system_update_pending, R.drawable.system_update_fill0) +} + +enum class MyNotificationChannel(val id: String, val text: Int) { + LockTaskMode("LockTaskMode", R.string.lock_task_mode), + Events("Events", R.string.events) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt index bcd078c..7dbadb7 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Receiver.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Receiver.kt @@ -1,6 +1,5 @@ package com.bintianqi.owndroid -import android.app.NotificationManager import android.app.PendingIntent import android.app.admin.DeviceAdminReceiver import android.content.ComponentName @@ -17,9 +16,6 @@ import com.bintianqi.owndroid.dpm.processSecurityLogs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale class Receiver : DeviceAdminReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -75,68 +71,59 @@ class Receiver : DeviceAdminReceiver() { override fun onLockTaskModeEntering(context: Context, intent: Intent, pkg: String) { super.onLockTaskModeEntering(context, intent, pkg) - if(!NotificationUtils.checkPermission(context)) return - NotificationUtils.registerChannels(context) + if (!NotificationUtils.checkPermission(context)) return val intent = Intent(context, this::class.java).setAction("com.bintianqi.owndroid.action.STOP_LOCK_TASK_MODE") val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.LOCK_TASK_MODE) + val builder = NotificationCompat.Builder(context, MyNotificationChannel.LockTaskMode.id) .setContentTitle(context.getText(R.string.lock_task_mode)) .setSmallIcon(R.drawable.lock_fill0) .addAction(NotificationCompat.Action.Builder(null, context.getString(R.string.stop), pendingIntent).build()) - NotificationUtils.notify(context, NotificationUtils.ID.LOCK_TASK_MODE, builder.build()) + NotificationUtils.notify(context, NotificationType.LockTaskMode, builder.build()) } override fun onLockTaskModeExiting(context: Context, intent: Intent) { super.onLockTaskModeExiting(context, intent) - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.cancel(NotificationUtils.ID.LOCK_TASK_MODE) + NotificationUtils.cancel(context, NotificationType.LockTaskMode) } override fun onPasswordChanged(context: Context, intent: Intent, userHandle: UserHandle) { super.onPasswordChanged(context, intent, userHandle) - sendUserRelatedNotification(context, userHandle, NotificationUtils.ID.PASSWORD_CHANGED, R.string.password_changed, R.drawable.password_fill0) + sendUserRelatedNotification(context, userHandle, NotificationType.PasswordChanged) } override fun onUserAdded(context: Context, intent: Intent, addedUser: UserHandle) { super.onUserAdded(context, intent, addedUser) - sendUserRelatedNotification(context, addedUser, NotificationUtils.ID.USER_ADDED, R.string.user_added, R.drawable.person_add_fill0) + sendUserRelatedNotification(context, addedUser, NotificationType.UserAdded) } override fun onUserStarted(context: Context, intent: Intent, startedUser: UserHandle) { super.onUserStarted(context, intent, startedUser) - sendUserRelatedNotification(context, startedUser, NotificationUtils.ID.USER_STARTED, R.string.user_started, R.drawable.person_fill0) + sendUserRelatedNotification(context, startedUser, NotificationType.UserStarted) } override fun onUserSwitched(context: Context, intent: Intent, switchedUser: UserHandle) { super.onUserSwitched(context, intent, switchedUser) - sendUserRelatedNotification(context, switchedUser, NotificationUtils.ID.USER_SWITCHED, R.string.user_switched, R.drawable.person_fill0) + sendUserRelatedNotification(context, switchedUser, NotificationType.UserSwitched) } override fun onUserStopped(context: Context, intent: Intent, stoppedUser: UserHandle) { super.onUserStopped(context, intent, stoppedUser) - sendUserRelatedNotification(context, stoppedUser, NotificationUtils.ID.USER_STOPPED, R.string.user_stopped, R.drawable.person_fill0) + sendUserRelatedNotification(context, stoppedUser, NotificationType.UserStopped) } override fun onUserRemoved(context: Context, intent: Intent, removedUser: UserHandle) { super.onUserRemoved(context, intent, removedUser) - sendUserRelatedNotification(context, removedUser, NotificationUtils.ID.USER_REMOVED, R.string.user_removed, R.drawable.person_remove_fill0) + sendUserRelatedNotification(context, removedUser, NotificationType.UserRemoved) } override fun onBugreportShared(context: Context, intent: Intent, hash: String) { super.onBugreportShared(context, intent, hash) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.bug_report_shared)) - .setContentText("SHA-256 hash: $hash") - .setSmallIcon(R.drawable.bug_report_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.BUG_REPORT_SHARED, builder.build()) + NotificationUtils.notifyEvent(context, NotificationType.BugReportShared, "SHA-256 hash: $hash") } override fun onBugreportSharingDeclined(context: Context, intent: Intent) { super.onBugreportSharingDeclined(context, intent) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.bug_report_sharing_declined)) - .setSmallIcon(R.drawable.bug_report_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.BUG_REPORT_SHARING_DECLINED, builder.build()) + NotificationUtils.notifyEvent(context, NotificationType.BugReportSharingDeclined, "") } override fun onBugreportFailed(context: Context, intent: Intent, failureCode: Int) { @@ -146,30 +133,21 @@ class Receiver : DeviceAdminReceiver() { BUGREPORT_FAILURE_FILE_NO_LONGER_AVAILABLE -> R.string.bug_report_failure_no_longer_available else -> R.string.place_holder } - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.bug_report_failed)) - .setContentText(context.getString(message)) - .setSmallIcon(R.drawable.bug_report_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.BUG_REPORT_FAILED, builder.build()) + NotificationUtils.notifyEvent(context, NotificationType.BugReportFailed, context.getString(message)) } override fun onSystemUpdatePending(context: Context, intent: Intent, receivedTime: Long) { super.onSystemUpdatePending(context, intent, receivedTime) - val time = SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(Date(receivedTime)) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(R.string.system_update_pending)) - .setContentText(context.getString(R.string.received_time) + ": $time") - .setSmallIcon(R.drawable.system_update_fill0) - NotificationUtils.notify(context, NotificationUtils.ID.SYSTEM_UPDATE_PENDING, builder.build()) + val text = context.getString(R.string.received_time) + ": " + formatDate(receivedTime) + NotificationUtils.notifyEvent(context, NotificationType.SystemUpdatePending, text) } - private fun sendUserRelatedNotification(context: Context, userHandle: UserHandle, id: Int, title: Int, icon: Int) { + private fun sendUserRelatedNotification( + context: Context, userHandle: UserHandle, type: NotificationType + ) { val um = context.getSystemService(Context.USER_SERVICE) as UserManager val serial = um.getSerialNumberForUser(userHandle) - val builder = NotificationCompat.Builder(context, NotificationUtils.Channel.EVENTS) - .setContentTitle(context.getString(title)) - .setContentText(context.getString(R.string.serial_number) + ": $serial") - .setSmallIcon(icon) - NotificationUtils.notify(context, id, builder.build()) + val text = context.getString(R.string.serial_number) + ": $serial" + NotificationUtils.notifyEvent(context, type, text) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/Settings.kt b/app/src/main/java/com/bintianqi/owndroid/Settings.kt index 7691225..24e0185 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Settings.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Settings.kt @@ -17,9 +17,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -39,24 +37,23 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.core.content.edit import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bintianqi.owndroid.ui.FunctionItem @@ -64,8 +61,8 @@ import com.bintianqi.owndroid.ui.MyScaffold import com.bintianqi.owndroid.ui.NavIcon import com.bintianqi.owndroid.ui.Notes import com.bintianqi.owndroid.ui.SwitchItem +import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable -import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -142,20 +139,25 @@ fun SettingsScreen(onNavigateUp: () -> Unit, onNavigate: (Any) -> Unit) { @Serializable object SettingsOptions @Composable -fun SettingsOptionsScreen(onNavigateUp: () -> Unit) { - val context = LocalContext.current +fun SettingsOptionsScreen( + getDisplayDangerousFeatures: () -> Boolean, getShortcutsEnabled: () -> Boolean, + setDisplayDangerousFeatures: (Boolean) -> Unit, setShortcutsEnabled: (Boolean) -> Unit, + onNavigateUp: () -> Unit +) { + var dangerousFeatures by remember { mutableStateOf(getDisplayDangerousFeatures()) } + var shortcuts by remember { mutableStateOf(getShortcutsEnabled()) } MyScaffold(R.string.options, onNavigateUp, 0.dp) { SwitchItem( - R.string.show_dangerous_features, icon = R.drawable.warning_fill0, - getState = { SP.displayDangerousFeatures }, - onCheckedChange = { SP.displayDangerousFeatures = it } + R.string.show_dangerous_features, dangerousFeatures, { + setDisplayDangerousFeatures(it) + dangerousFeatures = it + }, R.drawable.warning_fill0 ) SwitchItem( - R.string.shortcuts, icon = R.drawable.open_in_new, - getState = { SP.shortcuts }, onCheckedChange = { - SP.shortcuts = it - ShortcutUtils.setAllShortcuts(context) - } + R.string.shortcuts, shortcuts, { + setShortcutsEnabled(it) + shortcuts = it + }, R.drawable.open_in_new ) } } @@ -163,13 +165,12 @@ fun SettingsOptionsScreen(onNavigateUp: () -> Unit) { @Serializable object Appearance @Composable -fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onThemeChange: (ThemeSettings) -> Unit) { +fun AppearanceScreen( + onNavigateUp: () -> Unit, currentTheme: StateFlow, + setTheme: (ThemeSettings) -> Unit +) { var darkThemeMenu by remember { mutableStateOf(false) } - var theme by remember { mutableStateOf(currentTheme) } - fun update(it: ThemeSettings) { - theme = it - onThemeChange(it) - } + val theme by currentTheme.collectAsStateWithLifecycle() val darkThemeTextID = when(theme.darkTheme) { 1 -> R.string.on 0 -> R.string.off @@ -180,7 +181,7 @@ fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onTh SwitchItem( R.string.material_you_color, state = theme.materialYou, - onCheckedChange = { update(theme.copy(materialYou = it)) } + onCheckedChange = { setTheme(theme.copy(materialYou = it)) } ) } Box { @@ -192,22 +193,21 @@ fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onTh DropdownMenuItem( text = { Text(stringResource(R.string.follow_system)) }, onClick = { - update(theme.copy(darkTheme = -1)) + setTheme(theme.copy(darkTheme = -1)) darkThemeMenu = false } ) DropdownMenuItem( text = { Text(stringResource(R.string.on)) }, onClick = { - update(theme.copy(darkTheme = 1)) - theme = theme.copy(darkTheme = 1) + setTheme(theme.copy(darkTheme = 1)) darkThemeMenu = false } ) DropdownMenuItem( text = { Text(stringResource(R.string.off)) }, onClick = { - update(theme.copy(darkTheme = 0)) + setTheme(theme.copy(darkTheme = 0)) darkThemeMenu = false } ) @@ -216,148 +216,138 @@ fun AppearanceScreen(onNavigateUp: () -> Unit, currentTheme: ThemeSettings, onTh AnimatedVisibility(theme.darkTheme == 1 || (theme.darkTheme == -1 && isSystemInDarkTheme())) { SwitchItem( R.string.black_theme, state = theme.blackTheme, - onCheckedChange = { update(theme.copy(blackTheme = it)) } + onCheckedChange = { setTheme(theme.copy(blackTheme = it)) } ) } } } +data class AppLockConfig( + /** null means no password, empty means password already set */ + val password: String?, val biometrics: Boolean, val whenLeaving: Boolean +) + @Serializable object AppLockSettings @Composable -fun AppLockSettingsScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.app_lock, onNavigateUp, 0.dp) { - val fm = LocalFocusManager.current +fun AppLockSettingsScreen( + getConfig: () -> AppLockConfig, setConfig: (AppLockConfig) -> Unit, + onNavigateUp: () -> Unit +) = MyScaffold(R.string.app_lock, onNavigateUp) { var password by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf("") } - var allowBiometrics by remember { mutableStateOf(SP.biometricsUnlock) } - var lockWhenLeaving by remember { mutableStateOf(SP.lockWhenLeaving) } - val fr = remember { FocusRequester() } - val alreadySet = !SP.lockPasswordHash.isNullOrEmpty() - val isInputLegal = password.length !in 1..3 && (alreadySet || (password.isNotEmpty() && password.isNotBlank())) - Column(Modifier - .widthIn(max = 300.dp) - .align(Alignment.CenterHorizontally)) { - OutlinedTextField( - password, { password = it }, Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - label = { Text(stringResource(R.string.password)) }, - supportingText = { Text(stringResource(if(alreadySet) R.string.leave_empty_to_remain_unchanged else R.string.minimum_length_4)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions { fr.requestFocus() } - ) - OutlinedTextField( - confirmPassword, { confirmPassword = it }, Modifier - .fillMaxWidth() - .focusRequester(fr), - label = { Text(stringResource(R.string.confirm_password)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { fm.clearFocus() } - ) - if(VERSION.SDK_INT >= 28) Row(Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(stringResource(R.string.allow_biometrics)) - Switch(allowBiometrics, { allowBiometrics = it }) - } - Row(Modifier - .fillMaxWidth() - .padding(bottom = 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text(stringResource(R.string.lock_when_leaving)) - Switch(lockWhenLeaving, { lockWhenLeaving = it }) - } - Button( - onClick = { - fm.clearFocus() - if(password.isNotEmpty()) SP.lockPasswordHash = password.hash() - SP.biometricsUnlock = allowBiometrics - SP.lockWhenLeaving = lockWhenLeaving - onNavigateUp() - }, - modifier = Modifier.fillMaxWidth(), - enabled = isInputLegal && confirmPassword == password - ) { - Text(stringResource(if(alreadySet) R.string.update else R.string.set)) - } - if(alreadySet) FilledTonalButton( - onClick = { - fm.clearFocus() - SP.lockPasswordHash = "" - SP.biometricsUnlock = false - SP.lockWhenLeaving = false - onNavigateUp() - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.disable)) - } + var allowBiometrics by remember { mutableStateOf(false) } + var lockWhenLeaving by remember { mutableStateOf(false) } + var alreadySet by remember { mutableStateOf(false) } + val isInputLegal = password.length !in 1..3 && (alreadySet || password.isNotBlank()) + LaunchedEffect(Unit) { + val config = getConfig() + password = config.password ?: "" + allowBiometrics = config.biometrics + lockWhenLeaving = config.whenLeaving + alreadySet = config.password != null + } + OutlinedTextField( + password, { password = it }, Modifier.fillMaxWidth().padding(vertical = 4.dp), + label = { Text(stringResource(R.string.password)) }, + supportingText = { Text(stringResource(if(alreadySet) R.string.leave_empty_to_remain_unchanged else R.string.minimum_length_4)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next) + ) + OutlinedTextField( + confirmPassword, { confirmPassword = it }, Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.confirm_password)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done) + ) + if (VERSION.SDK_INT >= 28) Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(R.string.allow_biometrics)) + Switch(allowBiometrics, { allowBiometrics = it }) + } + Row( + Modifier.fillMaxWidth().padding(bottom = 6.dp), + Arrangement.SpaceBetween, Alignment.CenterVertically + ) { + Text(stringResource(R.string.lock_when_leaving)) + Switch(lockWhenLeaving, { lockWhenLeaving = it }) + } + Button( + onClick = { + setConfig(AppLockConfig(password, allowBiometrics, lockWhenLeaving)) + onNavigateUp() + }, + modifier = Modifier.fillMaxWidth(), + enabled = isInputLegal && confirmPassword == password + ) { + Text(stringResource(if(alreadySet) R.string.update else R.string.set)) + } + if (alreadySet) FilledTonalButton( + onClick = { + setConfig(AppLockConfig(null, false, false)) + onNavigateUp() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.disable)) } } @Serializable object ApiSettings @Composable -fun ApiSettings(onNavigateUp: () -> Unit) { +fun ApiSettings( + getEnabled: () -> Boolean, setKey: (String) -> Unit, onNavigateUp: () -> Unit +) { val context = LocalContext.current + var alreadyEnabled by remember { mutableStateOf(getEnabled()) } MyScaffold(R.string.api, onNavigateUp) { - var enabled by remember { mutableStateOf(SP.isApiEnabled) } + var enabled by remember { mutableStateOf(alreadyEnabled) } + var key by remember { mutableStateOf("") } SwitchItem(R.string.enable, state = enabled, onCheckedChange = { enabled = it - SP.isApiEnabled = it - if(!it) SP.sharedPrefs.edit { remove("api.key") } }, padding = false) - if(enabled) { - var key by remember { mutableStateOf("") } + if (enabled) { OutlinedTextField( - value = key, onValueChange = { key = it }, label = { Text(stringResource(R.string.api_key)) }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 4.dp), readOnly = true, + key, { key = it }, Modifier.fillMaxWidth().padding(bottom = 4.dp), + label = { Text(stringResource(R.string.api_key)) }, trailingIcon = { - IconButton( - onClick = { - val charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - val sr = SecureRandom() - key = (1..20).map { charset[sr.nextInt(charset.length)] }.joinToString("") - } - ) { - Icon(painter = painterResource(R.drawable.casino_fill0), contentDescription = "Random") + IconButton({ key = generateBase64Key(10) }) { + Icon(painterResource(R.drawable.casino_fill0), null) } } ) - Button( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp), - onClick = { - SP.apiKey = key - context.showOperationResultToast(true) - }, - enabled = key.isNotEmpty() - ) { - Text(stringResource(R.string.apply)) - } - if(SP.apiKey != null) Notes(R.string.api_key_exist) } + Button( + onClick = { + setKey(if (enabled) key else "") + alreadyEnabled = enabled + context.showOperationResultToast(true) + }, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + enabled = !enabled || key.length !in 0..7 + ) { + Text(stringResource(R.string.apply)) + } + if (enabled && alreadyEnabled) Notes(R.string.api_key_exist) } } @Serializable object Notifications @Composable -fun NotificationsScreen(onNavigateUp: () -> Unit) = MyScaffold(R.string.notifications, onNavigateUp, 0.dp) { - val sp = LocalContext.current.getSharedPreferences("data", Context.MODE_PRIVATE) - val map = mapOf( - NotificationUtils.ID.PASSWORD_CHANGED to R.string.password_changed, NotificationUtils.ID.USER_ADDED to R.string.user_added, - NotificationUtils.ID.USER_STARTED to R.string.user_started, NotificationUtils.ID.USER_SWITCHED to R.string.user_switched, - NotificationUtils.ID.USER_STOPPED to R.string.user_stopped, NotificationUtils.ID.USER_REMOVED to R.string.user_removed, - NotificationUtils.ID.BUG_REPORT_SHARED to R.string.bug_report_shared, - NotificationUtils.ID.BUG_REPORT_SHARING_DECLINED to R.string.bug_report_sharing_declined, - NotificationUtils.ID.BUG_REPORT_FAILED to R.string.bug_report_failed, - NotificationUtils.ID.SYSTEM_UPDATE_PENDING to R.string.system_update_pending - ) - map.forEach { (k, v) -> - SwitchItem(v, getState = { sp.getBoolean("n_$k", true) }, onCheckedChange = { sp.edit(true) { putBoolean("n_$k", it) } }) +fun NotificationsScreen( + getState: () -> List, setNotification: (NotificationType, Boolean) -> Unit, + onNavigateUp: () -> Unit +) = MyScaffold(R.string.notifications, onNavigateUp, 0.dp) { + val enabledNotifications = remember { mutableStateListOf(*getState().toTypedArray()) } + NotificationType.entries.forEach { type -> + SwitchItem(type.text, type in enabledNotifications, { + setNotification(type, it) + enabledNotifications.run { if (it) plusAssign(type) else minusAssign(type) } + }) } } diff --git a/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt b/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt index 2dd9369..27a880e 100644 --- a/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt +++ b/app/src/main/java/com/bintianqi/owndroid/SharedPrefs.kt @@ -13,8 +13,7 @@ class SharedPrefs(context: Context) { var dhizuku by BooleanSharedPref("dhizuku_mode") var isDefaultAffiliationIdSet by BooleanSharedPref("default_affiliation_id_set") var displayDangerousFeatures by BooleanSharedPref("display_dangerous_features") - var isApiEnabled by BooleanSharedPref("api.enabled") - var apiKey by StringSharedPref("api.key") + var apiKeyHash by StringSharedPref("api_key_hash") var materialYou by BooleanSharedPref("theme.material_you", Build.VERSION.SDK_INT >= 31) /** -1: follow system, 0: off, 1: on */ var darkTheme by IntSharedPref("theme.dark", -1) @@ -25,6 +24,8 @@ class SharedPrefs(context: Context) { var applicationsListView by BooleanSharedPref("applications.list_view", true) var shortcuts by BooleanSharedPref("shortcuts") var dhizukuServer by BooleanSharedPref("dhizuku_server") + var notifications by StringSharedPref("notifications") + var shortcutKey by StringSharedPref("shortcut_key") } private class BooleanSharedPref(val key: String, val defValue: Boolean = false): ReadWriteProperty { diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt index 45ca5fa..edc0056 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutUtils.kt @@ -5,10 +5,13 @@ import android.content.Intent import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat +import java.security.SecureRandom +import kotlin.io.encoding.Base64 object ShortcutUtils { - fun setAllShortcuts(context: Context) { - if (SP.shortcuts) { + fun setAllShortcuts(context: Context, enabled: Boolean) { + if (enabled) { + setShortcutKey() val list = listOf( createShortcut(context, MyShortcut.Lock, true), createShortcut(context, MyShortcut.DisableCamera, @@ -22,6 +25,7 @@ object ShortcutUtils { } } fun setShortcut(context: Context, shortcut: MyShortcut, state: Boolean) { + setShortcutKey() ShortcutManagerCompat.pushDynamicShortcut( context, createShortcut(context, shortcut, state) ) @@ -41,9 +45,47 @@ object ShortcutUtils { .setIntent( Intent(context, ShortcutsReceiverActivity::class.java) .setAction("com.bintianqi.owndroid.action.${shortcut.id}") + .putExtra("key", SP.shortcutKey) ) .build() } + /** @param state If true, set the user restriction */ + fun createUserRestrictionShortcut(context: Context, id: String, state: Boolean): ShortcutInfoCompat { + val restriction = UserRestrictionsRepository.findRestrictionById(id) + val label = context.getString(if (state) R.string.disable else R.string.enable) + " " + + context.getString(restriction.name) + setShortcutKey() + return ShortcutInfoCompat.Builder(context, "USER_RESTRICTION-$id") + .setIcon(IconCompat.createWithResource(context, restriction.icon)) + .setShortLabel(label) + .setIntent( + Intent(context, ShortcutsReceiverActivity::class.java) + .setAction("com.bintianqi.owndroid.action.USER_RESTRICTION") + .putExtra("restriction", id) + .putExtra("state", state) + .putExtra("key", SP.shortcutKey) + ) + .build() + } + fun setUserRestrictionShortcut(context: Context, id: String, state: Boolean): Boolean { + val shortcut = createUserRestrictionShortcut(context, id, state) + return ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) + } + fun updateUserRestrictionShortcut(context: Context, id: String, state: Boolean, checkExist: Boolean) { + if (checkExist) { + val shortcuts = ShortcutManagerCompat.getShortcuts( + context, ShortcutManagerCompat.FLAG_MATCH_PINNED + ) + if (shortcuts.find { it.id == "USER_RESTRICTION-$id" } == null) return + } + val shortcut = createUserRestrictionShortcut(context, id, state) + ShortcutManagerCompat.updateShortcuts(context, listOf(shortcut)) + } + fun setShortcutKey() { + if (SP.shortcutKey.isNullOrEmpty()) { + SP.shortcutKey = generateBase64Key(10) + } + } } enum class MyShortcut( diff --git a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt index 780adb1..7c1c7cd 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ShortcutsReceiverActivity.kt @@ -9,7 +9,9 @@ class ShortcutsReceiverActivity : Activity() { super.onCreate(savedInstanceState) try { val action = intent.action?.removePrefix("com.bintianqi.owndroid.action.") - if (action != null && SP.shortcuts) { + val key = SP.shortcutKey + val requestKey = intent?.getStringExtra("key") + if (action != null && SP.shortcuts && key != null && requestKey == key) { when (action) { "LOCK" -> Privilege.DPM.lockNow() "DISABLE_CAMERA" -> { @@ -22,9 +24,22 @@ class ShortcutsReceiverActivity : Activity() { Privilege.DPM.setMasterVolumeMuted(Privilege.DAR, !state) ShortcutUtils.setShortcut(this, MyShortcut.Mute, state) } + "USER_RESTRICTION" -> { + val state = intent?.getBooleanExtra("state", false) + val id = intent?.getStringExtra("restriction") + if (state == null || id == null) return + if (state) { + Privilege.DPM.addUserRestriction(Privilege.DAR, id) + } else { + Privilege.DPM.clearUserRestriction(Privilege.DAR, id) + } + ShortcutUtils.updateUserRestrictionShortcut(this, id, !state, false) + } } Log.d(TAG, "Received intent: $action") showOperationResultToast(true) + } else { + showOperationResultToast(false) } } finally { finish() diff --git a/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt b/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt index 8f18f52..e00916b 100644 --- a/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt +++ b/app/src/main/java/com/bintianqi/owndroid/UserRestrictionsRepository.kt @@ -98,6 +98,13 @@ object UserRestrictionsRepository { UserRestrictionCategory.Other -> other }.filter { Build.VERSION.SDK_INT >= it.requiresApi } } + fun findRestrictionById(id: String): Restriction { + listOf(network, connectivity, applications, media, users, other).forEach { list -> + val restriction = list.find { it.id == id } + if (restriction != null) return restriction + } + throw Exception("User restriction not found") + } } enum class UserRestrictionCategory(val title: Int, val icon: Int) { diff --git a/app/src/main/java/com/bintianqi/owndroid/Utils.kt b/app/src/main/java/com/bintianqi/owndroid/Utils.kt index 88a3e3d..ed81a05 100644 --- a/app/src/main/java/com/bintianqi/owndroid/Utils.kt +++ b/app/src/main/java/com/bintianqi/owndroid/Utils.kt @@ -18,10 +18,12 @@ import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream import java.security.MessageDigest +import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.concurrent.TimeUnit +import kotlin.io.encoding.Base64 var zhCN = true @@ -64,8 +66,8 @@ fun formatFileSize(bytes: Long): String { val Boolean.yesOrNo @StringRes get() = if(this) R.string.yes else R.string.no -fun formatTime(ms: Long): String { - return SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(Date(ms)) +fun formatDate(ms: Long): String { + return formatDate(Date(ms)) } fun formatDate(date: Date): String { return SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.getDefault()).format(date) @@ -121,3 +123,9 @@ class SerializableSaver(val serializer: KSerializer) : Saver { return Json.encodeToString(serializer, value) } } + +fun generateBase64Key(length: Int): String { + val ba = ByteArray(length) + SecureRandom().nextBytes(ba) + return Base64.withPadding(Base64.PaddingOption.ABSENT).encode(ba) +} diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt index ba81245..0e2214e 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/DPM.kt @@ -443,13 +443,13 @@ fun handlePrivilegeChange(context: Context) { SP.dhizukuServer = false SP.shortcuts = privilege.activated if (privilege.activated) { - ShortcutUtils.setAllShortcuts(context) + ShortcutUtils.setAllShortcuts(context, true) if (!privilege.dhizuku) { setDefaultAffiliationID() } } else { SP.isDefaultAffiliationIdSet = false - ShortcutUtils.setAllShortcuts(context) - SP.isApiEnabled = false + ShortcutUtils.setAllShortcuts(context, false) + SP.apiKeyHash = "" } } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt index 1f4cef1..df3e91f 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Network.kt @@ -70,7 +70,6 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -115,7 +114,7 @@ import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.formatFileSize -import com.bintianqi.owndroid.formatTime +import com.bintianqi.owndroid.formatDate import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.ErrorDialog @@ -511,7 +510,7 @@ fun UpdateNetworkScreen(info: WifiInfo, setNetwork: (WifiInfo) -> Boolean, onNav TopAppBar( { Text(stringResource(R.string.update_network)) }, navigationIcon = { NavIcon(onNavigateUp) }, - colors = TopAppBarDefaults.topAppBarColors(colorScheme.surfaceContainer) + colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainer) ) }, contentWindowInsets = WindowInsets.ime @@ -1046,14 +1045,14 @@ fun NetworkStatsScreen( } } OutlinedTextField( - value = startTime.let { if(it == -1L) "" else formatTime(it) }, onValueChange = {}, readOnly = true, + value = startTime.let { if(it == -1L) "" else formatDate(it) }, onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.start_time)) }, interactionSource = startTimeIs, isError = startTime >= endTime, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) ) OutlinedTextField( - value = formatTime(endTime), onValueChange = {}, readOnly = true, + value = formatDate(endTime), onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.end_time)) }, interactionSource = endTimeIs, isError = startTime >= endTime, @@ -1292,7 +1291,7 @@ fun NetworkStatsViewerScreen( HorizontalPager(ps, Modifier.padding(top = 8.dp)) { page -> val item = data[index] Column(Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding)) { - Text(formatTime(item.startTime) + "\n~\n" + formatTime(item.endTime), + Text(formatDate(item.startTime) + "\n~\n" + formatDate(item.endTime), Modifier.align(Alignment.CenterHorizontally), textAlign = TextAlign.Center) Spacer(Modifier.height(5.dp)) val txBytes = item.txBytes @@ -1350,7 +1349,7 @@ enum class PrivateDnsMode(val id: Int, val text: Int) { Host(DevicePolicyManager.PRIVATE_DNS_MODE_PROVIDER_HOSTNAME, R.string.enabled) } -data class PrivateDnsConfiguration(val mode: PrivateDnsMode, val host: String) +data class PrivateDnsConfiguration(val mode: PrivateDnsMode?, val host: String) @Serializable object PrivateDns @@ -1362,7 +1361,7 @@ fun PrivateDnsScreen( ) { val context = LocalContext.current val focusMgr = LocalFocusManager.current - var mode by remember { mutableStateOf(PrivateDnsMode.Opportunistic) } + var mode by remember { mutableStateOf(PrivateDnsMode.Opportunistic) } var inputHost by remember { mutableStateOf("") } LaunchedEffect(Unit) { val conf = getPrivateDns() @@ -1385,7 +1384,8 @@ fun PrivateDnsScreen( val result = setPrivateDns(PrivateDnsConfiguration(mode, inputHost)) context.showOperationResultToast(result) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding) + modifier = Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding), + enabled = mode != null ) { Text(stringResource(R.string.apply)) } diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt index 6f957cd..13bdc79 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Password.kt @@ -30,6 +30,8 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.OutlinedTextField @@ -45,6 +47,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -56,6 +59,7 @@ import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP +import com.bintianqi.owndroid.generateBase64Key import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem @@ -250,7 +254,12 @@ fun ResetPasswordTokenScreen( OutlinedTextField( token, { token = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.token)) }, - supportingText = { Text("${token.length}/32") } + supportingText = { Text("${token.length}/32") }, + trailingIcon = { + IconButton({ token = generateBase64Key(24) }) { + Icon(painterResource(R.drawable.casino_fill0), null) + } + } ) Button( onClick = { @@ -265,7 +274,12 @@ fun ResetPasswordTokenScreen( } if (state.set && !state.active) Button( onClick = { - getIntent()?.let { launcher.launch(it) } + val intent = getIntent() + if (intent == null) { + context.showOperationResultToast(false) + } else { + launcher.launch(intent) + } }, modifier = Modifier.fillMaxWidth() ) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt index 7124ce5..2e829b3 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/System.kt @@ -112,7 +112,7 @@ import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.SP import com.bintianqi.owndroid.formatFileSize -import com.bintianqi.owndroid.formatTime +import com.bintianqi.owndroid.formatDate import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem @@ -588,7 +588,7 @@ fun ChangeTimeScreen(setTime: (Long, Boolean) -> Boolean, onNavigateUp: () -> Un ) { if(page == 0) { OutlinedTextField( - value = datePickerState.selectedDateMillis?.let { formatTime(it) } ?: "", + value = datePickerState.selectedDateMillis?.let { formatDate(it) } ?: "", onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.date)) }, interactionSource = dateInteractionSource, @@ -1436,9 +1436,9 @@ fun CaCertScreen( Text("Issuer", style = typography.labelLarge) SelectionContainer { Text(cert.issuer) } Text("Issued on", style = typography.labelLarge) - SelectionContainer { Text(formatTime(cert.issuedTime)) } + SelectionContainer { Text(formatDate(cert.issuedTime)) } Text("Expires on", style = typography.labelLarge) - SelectionContainer { Text(formatTime(cert.expiresTime)) } + SelectionContainer { Text(formatDate(cert.expiresTime)) } Text("SHA-256 fingerprint", style = typography.labelLarge) SelectionContainer { Text(cert.hash) } if (dialog == 2) Row( @@ -1929,7 +1929,7 @@ fun SystemUpdatePolicyScreen( if (VERSION.SDK_INT >= 26) { Column(Modifier.padding(HorizontalPadding)) { if (pendingUpdate.exists) { - Text(stringResource(R.string.update_received_time, formatTime(pendingUpdate.time))) + Text(stringResource(R.string.update_received_time, formatDate(pendingUpdate.time))) Text(stringResource(R.string.is_security_patch, stringResource(pendingUpdate.securityPatch.yesOrNo))) } else { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt index 2fa9197..6662ba9 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/UserRestriction.kt @@ -1,6 +1,8 @@ package com.bintianqi.owndroid.dpm import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -21,6 +24,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -54,6 +58,7 @@ import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R import com.bintianqi.owndroid.UserRestrictionCategory import com.bintianqi.owndroid.UserRestrictionsRepository +import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.FunctionItem import com.bintianqi.owndroid.ui.MyLazyScaffold @@ -117,6 +122,17 @@ fun UserRestrictionScreen( onNavigate(UserRestrictionOptions(it.name)) } } + Row( + Modifier + .padding(HorizontalPadding, 10.dp) + .fillMaxWidth() + .background(colorScheme.primaryContainer, RoundedCornerShape(8.dp)) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Outlined.Info, null, Modifier.padding(end = 8.dp), colorScheme.onPrimaryContainer) + Text(stringResource(R.string.user_restriction_tip), color = colorScheme.onPrimaryContainer) + } } } } @@ -128,7 +144,8 @@ data class UserRestrictionOptions(val id: String) @Composable fun UserRestrictionOptionsScreen( args: UserRestrictionOptions, userRestrictions: StateFlow>, - setRestriction: (String, Boolean) -> Boolean, onNavigateUp: () -> Unit + setRestriction: (String, Boolean) -> Boolean, setShortcut: (String) -> Boolean, + onNavigateUp: () -> Unit ) { val context = LocalContext.current val status by userRestrictions.collectAsStateWithLifecycle() @@ -136,7 +153,12 @@ fun UserRestrictionOptionsScreen( MyLazyScaffold(title, onNavigateUp) { items(items) { restriction -> Row( - Modifier.fillMaxWidth().padding(15.dp, 6.dp), + Modifier + .fillMaxWidth() + .combinedClickable(onClick = {}, onLongClick = { + if (!setShortcut(restriction.id)) context.popToast(R.string.unsupported) + }) + .padding(15.dp, 6.dp), Arrangement.SpaceBetween, Alignment.CenterVertically ) { Row(Modifier.weight(1F), verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt index 958c48a..30fd057 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/Users.kt @@ -58,7 +58,7 @@ import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.MyViewModel import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R -import com.bintianqi.owndroid.formatTime +import com.bintianqi.owndroid.formatDate import com.bintianqi.owndroid.popToast import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CircularProgressDialog @@ -205,7 +205,7 @@ fun UserInfoScreen(getInfo: () -> UserInformation, onNavigateUp: () -> Unit) { if (VERSION.SDK_INT >= 23) InfoItem(R.string.system_user, info.system.yesOrNo) if (VERSION.SDK_INT >= 34) InfoItem(R.string.admin_user, info.admin.yesOrNo) if (VERSION.SDK_INT >= 25) InfoItem(R.string.demo_user, info.demo.yesOrNo) - if (info.time != 0L) InfoItem(R.string.creation_time, formatTime(info.time)) + if (info.time != 0L) InfoItem(R.string.creation_time, formatDate(info.time)) if (VERSION.SDK_INT >= 28) { InfoItem(R.string.logout_enabled, info.logout.yesOrNo) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9cec37c..93940e9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -476,7 +476,6 @@ Пароль и блокировка экрана Информация о пароле - Оставьте пустым, чтобы удалить пароль Максимальное количество неудачных попыток ввода пароля Максимальное количество неудачных попыток Время истечения срока действия пароля @@ -492,17 +491,14 @@ Единый пароль Сбросить токен пароля Токен - Токен должен быть длиннее 32 байт Тoken activated Очистить Установить - Установите токен Токен будет автоматически активирован, если пароль не установлен. Сбросить пароль Подтвердите пароль Не запрашивать учетные данные при загрузке Требовать ввод - Сбросить пароль с помощью токена Требуемая сложность пароля Функции блокировки экрана (Keyguard) Включить все diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2f643f3..dfad27d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -501,7 +501,6 @@ Parola ve Kilit Ekranı Parola Bilgisi - Parolayı kaldırmak için boş bırakın Maksimum Başarısız Parola Maksimum başarısız deneme sayısı Parola Son Kullanma Zaman Aşımı @@ -516,17 +515,14 @@ Birleşik Parola Parola Sıfırlama Jetonu Jeton - Jeton 32 bayttan uzun olmalıdır Token activated Temizle Ayarla - Lütfen bir jeton ayarlayın Parola ayarlanmamışsa jeton otomatik olarak etkinleştirilir. Parolayı Sıfırla Parolayı Onayla Başlangıçta kimlik bilgisi sorma Giriş gerektir - Jeton ile parolayı sıfırla Gerekli Parola Karmaşıklığı Kilit Ekranı Özelliklerini Devre Dışı Bırak Hepsini Etkinleştir diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d855c24..dcaa54a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -372,6 +372,7 @@ 用户限制 + 长按一个条目以创建快捷方式 Profile owner无法使用部分功能 打开开关后会禁用对应的功能 工作资料中部分功能无效 @@ -489,7 +490,6 @@ 密码与锁屏 密码信息 - 留空以清除密码 最大密码错误次数 错误次数 密码失效超时 @@ -504,18 +504,15 @@ 一致的密码 密码重置令牌 令牌 - 令牌必须大于32字节 激活密码重置令牌 令牌已激活 清除 设置 - 请先设置令牌 没有密码时会自动激活令牌 重置密码 确认密码 启动(boot)时不要求密码 不允许其他设备管理员重置密码直至用户输入一次密码 - 使用令牌重置密码 密码复杂度要求 锁屏功能 启用全部 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf68326..7f0ff06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -406,6 +406,7 @@ User restriction + Long press an item to create a shortcut Profile owner can use limited function Turn on a switch to disable that function. Functions in work profile is limited. @@ -525,7 +526,6 @@ Password and keyguard Password Info - Keep empty to remove password Max failed passwords Maximum failed attempts Password expiration timeout @@ -540,18 +540,15 @@ Unified password Reset password token Token - The token must be longer than 32 byte Activate reset password token Token activated Clear Set - Please set a token Token will be automatically activated if no password is set. Reset password Confirm password Do not ask credentials on boot Require entry - Reset password with token Required password complexity Keyguard features Enable all