feat: import/export cross profile intent filters (#240)

Add intent filter presets
This commit is contained in:
BinTianqi
2026-02-12 15:19:50 +08:00
parent 64191761b7
commit baaf2871e7
8 changed files with 247 additions and 76 deletions

View File

@@ -494,7 +494,11 @@ fun Home(vm: MyViewModel, onLock: () -> Unit) {
)
}
composable<CrossProfileIntentFilter> {
CrossProfileIntentFilterScreen(vm::addCrossProfileIntentFilter, ::navigateUp)
CrossProfileIntentFilterScreen(
vm::addCrossProfileIntentFilter, vm::clearCrossProfileIntentFilters,
vm::importCrossProfileIntentFilters, vm::exportCrossProfileIntentFilters,
::navigateUp
)
}
composable<DeleteWorkProfile> { DeleteWorkProfileScreen(vm::wipeData, ::navigateUp) }

View File

@@ -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)

View File

@@ -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)"
}
}

View File

@@ -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<IntentFilterOptions> {
val list = mutableListOf<IntentFilterOptions>()
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);
}
}

View File

@@ -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<List<IntentFilterOptions>>(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

View File

@@ -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 }
)
}
}

View File

@@ -331,6 +331,8 @@
<string name="profile_max_time_out_desc">工作资料处于关闭状态的时间达到该限制后会挂起个人应用0为无限制</string>
<string name="personal_app_suspended_because_timeout">个人应用已经因此挂起:%1$s</string>
<string name="intent_filter">Intent过滤器</string>
<string name="presets">预设</string>
<string name="allow_file_sharing">允许分享文件</string>
<string name="direction">方向</string>
<string name="both_direction">双向</string>
<string name="work_to_personal">工作到个人</string>

View File

@@ -365,6 +365,8 @@
<string name="profile_max_time_out_desc">Personal apps will be suspended after the work profile is closed for this amount of time. 0 means no limit. </string>
<string name="personal_app_suspended_because_timeout">Personal app suspended because of this: %1$s</string>
<string name="intent_filter">Intent filter</string>
<string name="presets">Presets</string>
<string name="allow_file_sharing">Allow file sharing</string>
<string name="direction">Direction</string>
<string name="both_direction">Both direction</string>
<string name="work_to_personal">Work to personal</string>