From baaf2871e7bf3719bfcc361471acac915926a3f0 Mon Sep 17 00:00:00 2001 From: BinTianqi Date: Thu, 12 Feb 2026 15:19:50 +0800 Subject: [PATCH] feat: import/export cross profile intent filters (#240) Add intent filter presets --- .../com/bintianqi/owndroid/MainActivity.kt | 6 +- .../bintianqi/owndroid/ManageSpaceActivity.kt | 1 + .../java/com/bintianqi/owndroid/MyDbHelper.kt | 8 +- .../com/bintianqi/owndroid/MyRepository.kt | 26 ++ .../com/bintianqi/owndroid/MyViewModel.kt | 28 +- .../com/bintianqi/owndroid/dpm/WorkProfile.kt | 250 +++++++++++++----- app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 8 files changed, 247 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt index 5aedebe..a060c63 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MainActivity.kt @@ -494,7 +494,11 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) { ) } composable { - CrossProfileIntentFilterScreen(vm::addCrossProfileIntentFilter, ::navigateUp) + CrossProfileIntentFilterScreen( + vm::addCrossProfileIntentFilter, vm::clearCrossProfileIntentFilters, + vm::importCrossProfileIntentFilters, vm::exportCrossProfileIntentFilters, + ::navigateUp + ) } composable { DeleteWorkProfileScreen(vm::wipeData, ::navigateUp) } diff --git a/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt b/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt index db671b9..d756e9e 100644 --- a/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt +++ b/app/src/main/java/com/bintianqi/owndroid/ManageSpaceActivity.kt @@ -57,6 +57,7 @@ class ManageSpaceActivity: FragmentActivity() { cacheDir.deleteRecursively() codeCacheDir.deleteRecursively() if(Build.VERSION.SDK_INT >= 24) { + dataDir.resolve("databases").deleteRecursively() dataDir.resolve("shared_prefs").deleteRecursively() } else { val sharedPref = applicationContext.getSharedPreferences("data", MODE_PRIVATE) diff --git a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt index dfa0805..53770ab 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyDbHelper.kt @@ -4,12 +4,13 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper -class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 4) { +class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 5) { override fun onCreate(db: SQLiteDatabase) { db.execSQL(DHIZUKU_CLIENTS_TABLE) db.execSQL(SECURITY_LOGS_TABLE) db.execSQL(NETWORK_LOGS_TABLE) db.execSQL(APP_GROUPS_TABLE) + db.execSQL(CP_INTENTS_TABLE) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { if (oldVersion < 2) { @@ -21,6 +22,9 @@ class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 4) { if (oldVersion < 4) { db.execSQL(APP_GROUPS_TABLE) } + if (oldVersion < 5) { + db.execSQL(CP_INTENTS_TABLE) + } } companion object { const val DHIZUKU_CLIENTS_TABLE = "CREATE TABLE dhizuku_clients (uid INTEGER PRIMARY KEY," + @@ -33,5 +37,7 @@ class MyDbHelper(context: Context): SQLiteOpenHelper(context, "data", null, 4) { const val APP_GROUPS_TABLE = "CREATE TABLE app_groups(" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "name TEXT, apps TEXT)" + const val CP_INTENTS_TABLE = "CREATE TABLE cross_profile_intent_filters (" + + "action_str TEXT, category TEXT, mime_type TEXT, direction INTEGER)" } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt index 62f7c57..6c99053 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyRepository.kt @@ -10,6 +10,7 @@ import androidx.core.database.getIntOrNull import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull import com.bintianqi.owndroid.dpm.AppGroup +import com.bintianqi.owndroid.dpm.IntentFilterOptions import com.bintianqi.owndroid.dpm.NetworkLog import com.bintianqi.owndroid.dpm.SecurityEvent import com.bintianqi.owndroid.dpm.SecurityEventWithData @@ -248,4 +249,29 @@ class MyRepository(val dbHelper: MyDbHelper) { fun deleteAppGroup(id: Int) { dbHelper.writableDatabase.delete("app_groups", "id = ?", arrayOf(id.toString())) } + + fun setCrossProfileIntentFilter(data: IntentFilterOptions) { + val cv = ContentValues() + cv.put("action_str", data.action) + cv.put("category", data.category) + cv.put("mime_type", data.mimeType) + cv.put("direction", data.direction) + dbHelper.writableDatabase.insert("cross_profile_intent_filters", null, cv) + } + fun getAllCrossProfileIntentFilters(): List { + val list = mutableListOf() + dbHelper.readableDatabase.rawQuery( + "SELECT * FROM cross_profile_intent_filters", null + ).use { + while (it.moveToNext()) { + list += IntentFilterOptions( + it.getString(0), it.getString(1), it.getString(2), it.getInt(3) + ) + } + } + return list + } + fun deleteAllCrossProfileIntentFilters() { + dbHelper.writableDatabase.delete("cross_profile_intent_filters", null, null); + } } \ No newline at end of file diff --git a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt index 11ba1b9..9b0a2cf 100644 --- a/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt +++ b/app/src/main/java/com/bintianqi/owndroid/MyViewModel.kt @@ -73,7 +73,6 @@ import com.bintianqi.owndroid.dpm.DelegatedAdmin import com.bintianqi.owndroid.dpm.DeviceAdmin import com.bintianqi.owndroid.dpm.FrpPolicyInfo import com.bintianqi.owndroid.dpm.HardwareProperties -import com.bintianqi.owndroid.dpm.IntentFilterDirection import com.bintianqi.owndroid.dpm.IntentFilterOptions import com.bintianqi.owndroid.dpm.IpMode import com.bintianqi.owndroid.dpm.KeyguardDisableConfig @@ -1417,13 +1416,28 @@ class MyViewModel(application: Application): AndroidViewModel(application) { val filter = IntentFilter(options.action) if (options.category.isNotEmpty()) filter.addCategory(options.category) if (options.mimeType.isNotEmpty()) filter.addDataType(options.mimeType) - val flags = when(options.direction) { - IntentFilterDirection.ToManaged -> DevicePolicyManager.FLAG_PARENT_CAN_ACCESS_MANAGED - IntentFilterDirection.ToParent -> DevicePolicyManager.FLAG_MANAGED_CAN_ACCESS_PARENT - IntentFilterDirection.Both -> DevicePolicyManager.FLAG_PARENT_CAN_ACCESS_MANAGED or - DevicePolicyManager.FLAG_MANAGED_CAN_ACCESS_PARENT + DPM.addCrossProfileIntentFilter(DAR, filter, options.direction) + myRepo.setCrossProfileIntentFilter(options) + } + fun clearCrossProfileIntentFilters() { + DPM.clearCrossProfileIntentFilters(DAR) + myRepo.deleteAllCrossProfileIntentFilters() + } + fun importCrossProfileIntentFilters(uri: Uri) { + val bytes = application.contentResolver.openInputStream(uri)!!.use { + it.readBytes().decodeToString() + } + val data = Json.decodeFromString>(bytes) + data.forEach { + addCrossProfileIntentFilter(it) + } + } + fun exportCrossProfileIntentFilters(uri: Uri) { + val data = myRepo.getAllCrossProfileIntentFilters() + val bytes = Json.encodeToString(data).encodeToByteArray() + application.contentResolver.openOutputStream(uri)!!.use { + it.write(bytes) } - DPM.addCrossProfileIntentFilter(DAR, filter, flags) } val UM = application.getSystemService(Context.USER_SERVICE) as UserManager diff --git a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt b/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt index beb3cb3..7b1ca53 100644 --- a/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt +++ b/app/src/main/java/com/bintianqi/owndroid/dpm/WorkProfile.kt @@ -4,34 +4,46 @@ import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager.WIPE_EUICC import android.app.admin.DevicePolicyManager.WIPE_EXTERNAL_STORAGE import android.content.Intent +import android.net.Uri import android.os.Binder import android.os.Build.VERSION import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +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.MenuAnchorType import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -45,20 +57,24 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester 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.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bintianqi.owndroid.BottomPadding import com.bintianqi.owndroid.HorizontalPadding import com.bintianqi.owndroid.Privilege import com.bintianqi.owndroid.R +import com.bintianqi.owndroid.adaptiveInsets import com.bintianqi.owndroid.showOperationResultToast import com.bintianqi.owndroid.ui.CheckBoxItem import com.bintianqi.owndroid.ui.CircularProgressDialog import com.bintianqi.owndroid.ui.FullWidthCheckBoxItem import com.bintianqi.owndroid.ui.FunctionItem 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 com.bintianqi.owndroid.yesOrNo @@ -240,21 +256,24 @@ fun SuspendPersonalAppScreen( } } +@Serializable data class IntentFilterOptions( val action: String, val category: String, val mimeType: String, - val direction: IntentFilterDirection + val direction: Int // 1: private to work, 2: work to private, 3: both +) + +val crossProfileIntentFilterPresets = mapOf( + R.string.allow_file_sharing to + IntentFilterOptions(Intent.ACTION_SEND, Intent.CATEGORY_DEFAULT, "*/*", 3) ) -enum class IntentFilterDirection(val text: Int) { - ToParent(R.string.work_to_personal), ToManaged(R.string.personal_to_work), - Both(R.string.both_direction) -} @Serializable object CrossProfileIntentFilter @OptIn(ExperimentalMaterial3Api::class) @Composable fun CrossProfileIntentFilterScreen( - addFilter: (IntentFilterOptions) -> Unit, + addFilter: (IntentFilterOptions) -> Unit, clearFilters: () -> Unit, + importFilters: (Uri) -> Unit, exportFilters: (Uri) -> Unit, onNavigateUp: () -> Unit ) { val context = LocalContext.current @@ -265,74 +284,171 @@ fun CrossProfileIntentFilterScreen( var customMimeType by remember { mutableStateOf(false) } var mimeType by remember { mutableStateOf("") } var dropdown by remember { mutableStateOf(false) } - var direction by remember { mutableStateOf(IntentFilterDirection.Both) } - MyScaffold(R.string.intent_filter, onNavigateUp) { - OutlinedTextField( - value = action, onValueChange = { action = it }, - label = { Text("Action") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }), - modifier = Modifier.fillMaxWidth() + var direction by remember { mutableIntStateOf(3) } + var dialog by remember { mutableStateOf(false) } + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { + if (it != null) { + importFilters(it) + context.showOperationResultToast(true) + } + } + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { + if (it != null) { + exportFilters(it) + context.showOperationResultToast(true) + } + } + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.intent_filter)) }, + navigationIcon = { NavIcon(onNavigateUp) }, + actions = { + var menu by remember { mutableStateOf(false) } + Box { + IconButton({ menu = !menu }) { + Icon(Icons.Default.MoreVert, null) + } + DropdownMenu(menu, { menu = false }) { + DropdownMenuItem( + { Text(stringResource(R.string.presets)) }, + { + dialog = true + menu = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.list_fill0), null) + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.import_str)) }, + { + importLauncher.launch(arrayOf("application/json")) + menu = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.file_open_fill0), null) + } + ) + DropdownMenuItem( + { Text(stringResource(R.string.export)) }, + { + exportLauncher.launch("owndroid_intent_filters") + menu = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.file_export_fill0), null) + } + ) + } + } + } + ) + }, + contentWindowInsets = adaptiveInsets() + ) { paddingValues -> + val directionTextMap = mapOf( + 1 to R.string.personal_to_work, + 2 to R.string.work_to_personal, + 3 to R.string.both_direction ) - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Checkbox(customCategory, { - customCategory = it - category = "" - }) + Column( + Modifier + .padding(paddingValues) + .padding(horizontal = HorizontalPadding) + .verticalScroll(rememberScrollState()) + ) { OutlinedTextField( - category, { category = it }, Modifier.fillMaxWidth(), - label = { Text("Category") }, enabled = customCategory + value = action, onValueChange = { action = it }, + label = { Text("Action") }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = {focusMgr.clearFocus() }), + modifier = Modifier.fillMaxWidth() ) - } - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Checkbox(customMimeType, { - customMimeType = it - mimeType = "" - }) - OutlinedTextField( - mimeType, { mimeType = it }, Modifier.fillMaxWidth(), - label = { Text("MIME type") }, enabled = customMimeType - ) - } - ExposedDropdownMenuBox(dropdown, { dropdown = it }, Modifier.padding(vertical = 5.dp)) { - OutlinedTextField( - stringResource(direction.text), {}, - Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), - label = { Text(stringResource(R.string.direction)) }, readOnly = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dropdown) } - ) - ExposedDropdownMenu(dropdown, { dropdown = false }) { - IntentFilterDirection.entries.forEach { - DropdownMenuItem({ Text(stringResource(it.text)) }, { - direction = it - dropdown = false - }) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(customCategory, { + customCategory = it + category = "" + }) + OutlinedTextField( + category, { category = it }, Modifier.fillMaxWidth(), + label = { Text("Category") }, enabled = customCategory + ) + } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Checkbox(customMimeType, { + customMimeType = it + mimeType = "" + }) + OutlinedTextField( + mimeType, { mimeType = it }, Modifier.fillMaxWidth(), + label = { Text("MIME type") }, enabled = customMimeType + ) + } + ExposedDropdownMenuBox(dropdown, { dropdown = it }, Modifier.padding(vertical = 5.dp)) { + OutlinedTextField( + stringResource(directionTextMap[direction]!!), {}, + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + label = { Text(stringResource(R.string.direction)) }, readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dropdown) } + ) + ExposedDropdownMenu(dropdown, { dropdown = false }) { + directionTextMap.forEach { + DropdownMenuItem({ Text(stringResource(it.value)) }, { + direction = it.key + dropdown = false + }) + } } } + Button( + { + addFilter(IntentFilterOptions(action, category, mimeType, direction)) + context.showOperationResultToast(true) + }, + Modifier.fillMaxWidth(), + enabled = action.isNotBlank() && (!customCategory || category.isNotBlank()) && + (!customMimeType || mimeType.isNotBlank()) + ) { + Text(stringResource(R.string.add)) + } + Button( + onClick = { + clearFilters() + context.showOperationResultToast(true) + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) + ) { + Text(stringResource(R.string.clear_cross_profile_filters)) + } + Notes(R.string.info_cross_profile_intent_filter) + Spacer(Modifier.height(BottomPadding)) } - Button( - { - addFilter(IntentFilterOptions( - action, category, mimeType, direction - )) - context.showOperationResultToast(true) + if (dialog) AlertDialog( + title = { Text(stringResource(R.string.presets)) }, + text = { + crossProfileIntentFilterPresets.forEach { + Button({ + addFilter(it.value) + context.showOperationResultToast(true) + dialog = false + }) { + Text(stringResource(it.key)) + } + } }, - Modifier.fillMaxWidth(), - enabled = action.isNotBlank() && (!customCategory || category.isNotBlank()) && - (!customMimeType || mimeType.isNotBlank()) - ) { - Text(stringResource(R.string.add)) - } - Button( - onClick = { - Privilege.DPM.clearCrossProfileIntentFilters(Privilege.DAR) - context.showOperationResultToast(true) + confirmButton = { + TextButton({ dialog = false }) { + Text(stringResource(R.string.cancel)) + } }, - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp) - ) { - Text(stringResource(R.string.clear_cross_profile_filters)) - } - Notes(R.string.info_cross_profile_intent_filter) + onDismissRequest = { dialog = false } + ) } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 08879fb..0f8975e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -331,6 +331,8 @@ 工作资料处于关闭状态的时间达到该限制后会挂起个人应用,0为无限制 个人应用已经因此挂起:%1$s Intent过滤器 + 预设 + 允许分享文件 方向 双向 工作到个人 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8ff66c..1ba89ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -365,6 +365,8 @@ Personal apps will be suspended after the work profile is closed for this amount of time. 0 means no limit. Personal app suspended because of this: %1$s Intent filter + Presets + Allow file sharing Direction Both direction Work to personal