From 22963901f444d953f40c109b37b34bbb8e04a82a Mon Sep 17 00:00:00 2001 From: Daniel JB Clark Date: Tue, 14 Jul 2026 00:16:11 -0400 Subject: [PATCH 1/2] feat: add fleet/headless configuration profile support FleetProfileApplier parses JSON profiles and writes preferences to SharedPreferences, resolving human-readable key aliases (e.g. 'foreground_service') and value aliases (e.g. 'alarm' -> timed task backend key) to internal preference keys. FleetProfileActivity is an exported headless Activity (Theme.NoDisplay) that applies a fleet profile via am start without any UI. Profiles can be passed as a file path or content URI. This is useful for MDM, fleet management, provisioning tools, and CI/CD - anywhere you want to pre-configure AutoJs6 preferences without UI automation. --- .../pref/fleet/FleetProfileApplierTest.kt | 202 ++++++++++ app/src/main/AndroidManifest.xml | 20 + .../main/assets/fleet_profile_default.json | 31 ++ .../core/pref/fleet/FleetProfileActivity.kt | 158 ++++++++ .../core/pref/fleet/FleetProfileApplier.kt | 381 ++++++++++++++++++ docs/FLEET_PROFILE.md | 170 ++++++++ 6 files changed, 962 insertions(+) create mode 100644 app/src/androidTest/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplierTest.kt create mode 100644 app/src/main/assets/fleet_profile_default.json create mode 100644 app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileActivity.kt create mode 100644 app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplier.kt create mode 100644 docs/FLEET_PROFILE.md diff --git a/app/src/androidTest/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplierTest.kt b/app/src/androidTest/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplierTest.kt new file mode 100644 index 000000000..00650370a --- /dev/null +++ b/app/src/androidTest/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplierTest.kt @@ -0,0 +1,202 @@ +package org.autojs.autojs.core.pref.fleet + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** + * Tests for FleetProfileApplier running on an Android device. + * + * Uses a dedicated test SharedPreferences instance to avoid modifying the app's + * real preferences. Each test cleans up after itself. + */ +class FleetProfileApplierTest { + + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + private val prefs by lazy { + context.getSharedPreferences("test_fleet_profile", Context.MODE_PRIVATE) + } + + @Before + fun setUp() { + prefs.edit().clear().commit() + } + + @Test + fun applyJson_appliesBoolean() { + val result = FleetProfileApplier.applyJson(context, """{"foreground_service": true}""", prefs) + assertTrue("expected success", result.success) + assertEquals(1, result.appliedCount) + assertEquals(0, result.skippedCount) + assertEquals(listOf("foreground_service"), result.appliedKeys) + } + + @Test + fun applyJson_appliesString() { + val result = FleetProfileApplier.applyJson(context, """{"restart_strategy": "quick"}""", prefs) + assertTrue(result.success) + assertEquals(1, result.appliedCount) + assertEquals(listOf("restart_strategy"), result.appliedKeys) + } + + @Test + fun applyJson_appliesMultipleKeys() { + val json = """{ + "foreground_service": true, + "floating_menu_shown": false, + "stable_mode": true, + "guard_mode": true + }""" + val result = FleetProfileApplier.applyJson(context, json, prefs) + assertTrue(result.success) + assertEquals(4, result.appliedCount) + assertEquals(4, result.appliedKeys.size) + } + + @Test + fun applyJson_acceptsRawKey() { + val d = "${'$'}" + val result = FleetProfileApplier.applyJson(context, """{"key_${d}_foreground_service": true}""", prefs) + assertTrue(result.success) + assertEquals(1, result.appliedCount) + } + + @Test + fun applyJson_resolvesValueAlias() { + val result = FleetProfileApplier.applyJson(context, """{"timed_task_backend": "alarm"}""", prefs) + assertTrue("expected success", result.success) + assertEquals(1, result.appliedCount) + } + + @Test + fun applyJson_unknownKey_skipped() { + val result = FleetProfileApplier.applyJson(context, """{"nonexistent_key": true}""", prefs) + assertFalse("expected failure", result.success) + assertEquals(0, result.appliedCount) + assertEquals(1, result.skippedCount) + assertEquals(listOf("nonexistent_key"), result.failedKeys) + } + + @Test + fun applyJson_invalidJson_returnsError() { + val result = FleetProfileApplier.applyJson(context, """{invalid""", prefs) + assertFalse(result.success) + assertEquals(0, result.appliedCount) + assertTrue(result.errors.first().contains("Invalid JSON")) + } + + @Test + fun applyJson_skipsMetaKeys() { + val json = """{ + "_meta": {"name": "test"}, + "foreground_service": true + }""" + val result = FleetProfileApplier.applyJson(context, json, prefs) + assertTrue(result.success) + assertEquals(1, result.appliedCount) + } + + @Test + fun applyJson_clearExisting_clearsBeforeApply() { + FleetProfileApplier.applyJson(context, """{"foreground_service": true}""", prefs) + val result = FleetProfileApplier.applyJson(context, """{ + "_meta": {"clear_existing": true}, + "stable_mode": true + }""", prefs) + assertTrue(result.success) + assertEquals(1, result.appliedCount) + } + + @Test + fun applyJson_appliesInt() { + val result = FleetProfileApplier.applyJson(context, """{"editor_text_size": 18}""", prefs) + assertTrue(result.success) + assertEquals(1, result.appliedCount) + } + + @Test + fun applyJson_appliesFloat() { + val result = FleetProfileApplier.applyJson(context, """{"screen_capture_request_delay": 0.5}""", prefs) + assertTrue(result.success) + assertEquals(1, result.appliedCount) + } + + @Test + fun applyJson_appliesStringArray() { + val json = """{"file_extensions": ["js", "jsx"]}""" + val result = FleetProfileApplier.applyJson(context, json, prefs) + assertTrue(result.success) + assertEquals(1, result.appliedCount) + } + + @Test + fun result_toJson_includesAllFields() { + val result = FleetProfileApplier.Result( + success = true, appliedCount = 2, skippedCount = 0, + appliedKeys = listOf("a", "b"), failedKeys = emptyList(), + errors = emptyList(), message = "ok" + ) + val json = result.toJson() + assertTrue(json.getBoolean("success")) + assertEquals(2, json.getInt("applied_count")) + assertEquals(0, json.getInt("skipped_count")) + assertEquals(2, json.getJSONArray("applied_keys").length()) + assertEquals("ok", json.getString("message")) + } + + @Test + fun result_toLogLine_includesTimestamp() { + val result = FleetProfileApplier.Result( + success = true, appliedCount = 1, skippedCount = 0, + appliedKeys = listOf("x"), failedKeys = emptyList(), + errors = emptyList(), message = "ok" + ) + val line = result.toLogLine() + assertTrue("expected timestamp prefix", line.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z .*"""))) + assertTrue(line.contains("INFO ")) + assertTrue(line.contains("applied=1")) + assertTrue(line.contains("message=\"ok\"")) + } + + @Test + fun result_toLogLine_levelIsErrorOnFail() { + val result = FleetProfileApplier.Result( + success = false, appliedCount = 0, skippedCount = 1, + appliedKeys = emptyList(), failedKeys = listOf("bad"), + errors = listOf("Unknown key: bad"), message = "fail" + ) + val line = result.toLogLine() + assertTrue("expected ERROR level", line.contains("ERROR")) + assertTrue(line.contains("failed=bad")) + } + + @Test + fun result_toLogLine_levelIsWarnOnPartial() { + val result = FleetProfileApplier.Result( + success = false, appliedCount = 2, skippedCount = 1, + appliedKeys = listOf("a", "b"), failedKeys = listOf("c"), + errors = listOf("Unknown key: c"), message = "partial" + ) + val line = result.toLogLine() + assertTrue("expected WARN level on partial", line.contains("WARN ")) + } + + @Test + fun applyFromPath_parsesFile() { + val file = java.io.File(context.cacheDir, "test-fleet-profile.json") + file.writeText("""{"foreground_service": true}""", Charsets.UTF_8) + val result = FleetProfileApplier.applyFromPath(context, file.absolutePath) + assertTrue(result.success) + assertEquals(1, result.appliedCount) + file.delete() + } + + @Test + fun applyFromPath_missingFile_returnsError() { + val result = FleetProfileApplier.applyFromPath(context, "/nonexistent/path.json") + assertFalse(result.success) + assertTrue(result.message.contains("Failed to read")) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c1215110c..b3ff3fa8a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -409,6 +409,26 @@ + + + + + + + + + + + + + + + + FleetProfileApplier.applyFromUri(this, data) + path != null -> FleetProfileApplier.applyFromPath(this, path) + else -> FleetProfileApplier.Result( + success = false, appliedCount = 0, skippedCount = 0, + appliedKeys = emptyList(), failedKeys = emptyList(), + errors = listOf("Missing profile_path or data URI"), + message = "Missing profile_path or data URI" + ) + } + } catch (e: Exception) { + FleetProfileApplier.Result( + success = false, appliedCount = 0, skippedCount = 0, + appliedKeys = emptyList(), failedKeys = emptyList(), + errors = listOf(e.message ?: "Unknown error"), + message = "Failed to apply profile: ${e.message}" + ) + } + } + + private fun buildResultIntent(result: FleetProfileApplier.Result): Intent = Intent().apply { + putExtra(EXTRA_RESULT_SUCCESS, result.success) + putExtra(EXTRA_RESULT_APPLIED_COUNT, result.appliedCount) + putExtra(EXTRA_RESULT_SKIPPED_COUNT, result.skippedCount) + putExtra(EXTRA_RESULT_APPLIED_KEYS, result.appliedKeys.toTypedArray()) + putExtra(EXTRA_RESULT_FAILED_KEYS, result.failedKeys.toTypedArray()) + putExtra(EXTRA_RESULT_ERRORS, result.errors.toTypedArray()) + putExtra(EXTRA_RESULT_MESSAGE, result.message) + } + + private fun writeResultFile(result: FleetProfileApplier.Result) { + val file = resolveResultFile() + try { + file.parentFile?.mkdirs() + file.writeText(result.toJson().toString(2), Charsets.UTF_8) + } catch (_: Exception) { + } + } + + private fun resolveResultFile(): File { + intent.getStringExtra(EXTRA_RESULT_PATH)?.let { return File(EnvironmentUtils.normalizePath(it) ?: it) } + intent.getStringExtra(EXTRA_PROFILE_PATH)?.let { path -> + val normalized = EnvironmentUtils.normalizePath(path) ?: path + val parent = File(normalized).parentFile + if (parent != null && parent.exists()) { + return File(parent, DEFAULT_RESULT_FILENAME) + } + } + return File( + EnvironmentUtils.externalStorageDirectory, + DEFAULT_RESULT_FILENAME + ) + } + +} diff --git a/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplier.kt b/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplier.kt new file mode 100644 index 000000000..3de247423 --- /dev/null +++ b/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplier.kt @@ -0,0 +1,381 @@ +package org.autojs.autojs.core.pref.fleet + +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import android.util.Log +import androidx.annotation.VisibleForTesting +import androidx.core.content.edit +import org.autojs.autojs.core.pref.Pref +import org.autojs.autojs.util.StringUtils.key +import org.autojs.autojs6.R +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * Applies a fleet/headless configuration profile to AutoJs6's default SharedPreferences. + * + * Profiles are JSON objects mapping preference keys to typed values. Supported types: + * - boolean -> putBoolean + * - string -> putString + * - int -> putInt + * - long -> putLong + * - float -> putFloat + * - string[] -> putStringSet + * + * Known preference keys are exported in R.string.key_* and resolve to keys like + * "key_$_foreground_service". The profile can use either the raw key string or + * a short alias from the key alias table (e.g. "foreground_service"). + * + * For ListPreference-style keys, the value can be either the internal key string + * (e.g. "key_$_timed_task_backend_alarm") or a human-readable alias (e.g. "alarm"). + * + * Example profile: + * { + * "foreground_service": true, + * "floating_menu_shown": false, + * "enable_a11y_service_with_secure_settings": true, + * "stable_mode": true, + * "guard_mode": true, + * "restart_strategy": "quick", + * "timed_task_backend": "alarm", + * "file_extensions": "show_all" + * } + */ +object FleetProfileApplier { + + private const val KEY_PREFIX = "key_\$_" + + private val aliasToKey by lazy { + mapOf( + "foreground_service" to key(R.string.key_foreground_service), + "floating_menu_shown" to key(R.string.key_floating_menu_shown), + "a11y_service" to key(R.string.key_a11y_service), + "enable_a11y_service_with_root_access" to key(R.string.key_enable_a11y_service_with_root_access), + "enable_a11y_service_with_secure_settings" to key(R.string.key_enable_a11y_service_with_secure_settings), + "stable_mode" to key(R.string.key_stable_mode), + "guard_mode" to key(R.string.key_guard_mode), + "use_volume_control_running" to key(R.string.key_use_volume_control_running), + "use_volume_control_record" to key(R.string.key_use_volume_control_record), + "record_toast" to key(R.string.key_record_toast), + "extending_js_build_in_objects" to key(R.string.key_extending_js_build_in_objects), + "rhino_java_primitive_wrap" to key(R.string.key_rhino_java_primitive_wrap), + "auto_check_for_updates" to key(R.string.key_auto_check_for_updates), + "post_notifications_permission" to key(R.string.key_post_notifications_permission), + "display_over_other_apps" to key(R.string.key_display_over_other_apps), + "all_files_access" to key(R.string.key_all_files_access), + "root_mode" to key(R.string.key_root_mode), + "restart_strategy" to key(R.string.key_restart_strategy), + "timed_task_backend" to key(R.string.key_timed_task_backend), + "scheduled_restart_backend" to key(R.string.key_scheduled_restart_backend), + "night_mode" to key(R.string.key_night_mode), + "app_language" to key(R.string.key_app_language), + "theme_color" to key(R.string.key_theme_color), + "editor_theme" to key(R.string.key_editor_theme), + "editor_text_size" to key(R.string.key_editor_text_size), + "screen_capture_request_delay" to key(R.string.key_screen_capture_request_delay), + "file_extensions" to key(R.string.key_file_extensions), + "hidden_files" to key(R.string.key_hidden_files), + "working_directory" to key(R.string.key_working_directory), + "documentation_source" to key(R.string.key_documentation_source), + "server_address" to key(R.string.key_server_address), + "client_socket_normally_closed" to key(R.string.key_client_socket_normally_closed), + "server_socket_normally_closed" to key(R.string.key_server_socket_normally_closed), + "gesture_observing" to key(R.string.key_gesture_observing), + "launcher_icon" to key(R.string.key_launcher_icon), + "keep_screen_on_when_in_foreground" to key(R.string.key_keep_screen_on_when_in_foreground), + ) + } + + /** + * For ListPreference-style keys, the stored value must be one of the internal + * key strings (e.g. "key_$_timed_task_backend_alarm"). Fleet profiles are meant + * to be human-readable, so we also accept short aliases like "alarm" and map + * them to the real key strings. + */ + private val valueAliasToKey by lazy { + mapOf( + key(R.string.key_timed_task_backend) to mapOf( + "alarm" to key(R.string.key_timed_task_backend_alarm), + "work" to key(R.string.key_timed_task_backend_work), + "job" to key(R.string.key_timed_task_backend_job), + ), + key(R.string.key_scheduled_restart_backend) to mapOf( + "alarm_manager" to key(R.string.key_scheduled_restart_backend_alarm_manager), + "work_manager" to key(R.string.key_scheduled_restart_backend_work_manager), + ), + key(R.string.key_restart_strategy) to mapOf( + "quick" to key(R.string.key_restart_strategy_quick), + "scheduled" to key(R.string.key_restart_strategy_scheduled), + ), + key(R.string.key_night_mode) to mapOf( + "follow_system" to key(R.string.key_night_mode_follow_system), + "always_on" to key(R.string.key_night_mode_always_on), + "always_off" to key(R.string.key_night_mode_always_off), + ), + key(R.string.key_keep_screen_on_when_in_foreground) to mapOf( + "off" to key(R.string.key_keep_screen_on_when_in_foreground_disabled), + "disabled" to key(R.string.key_keep_screen_on_when_in_foreground_disabled), + "all_pages" to key(R.string.key_keep_screen_on_when_in_foreground_all_pages), + "homepage_only" to key(R.string.key_keep_screen_on_when_in_foreground_homepage_only), + ), + key(R.string.key_root_mode) to mapOf( + "auto_detect" to key(R.string.key_root_mode_auto_detect), + "force_root" to key(R.string.key_root_mode_force_root), + "force_non_root" to key(R.string.key_root_mode_force_non_root), + ), + key(R.string.key_hidden_files) to mapOf( + "show" to key(R.string.key_hidden_files_show), + "not_show" to key(R.string.key_hidden_files_not_show), + ), + key(R.string.key_file_extensions) to mapOf( + "show_all" to key(R.string.key_file_extensions_show_all), + "not_show" to key(R.string.key_file_extensions_not_show), + "show_all_but_executable" to key(R.string.key_file_extensions_show_all_but_executable), + ), + key(R.string.key_documentation_source) to mapOf( + "local" to key(R.string.key_documentation_source_local), + "online" to key(R.string.key_documentation_source_online), + ), + key(R.string.key_app_language) to mapOf( + "auto" to key(R.string.key_app_language_auto), + "zh_hans" to key(R.string.key_app_language_zh_hans), + "zh_hant_hk" to key(R.string.key_app_language_zh_hant_hk), + "zh_hant_tw" to key(R.string.key_app_language_zh_hant_tw), + "en" to key(R.string.key_app_language_en), + "fr" to key(R.string.key_app_language_fr), + "es" to key(R.string.key_app_language_es), + "ja" to key(R.string.key_app_language_ja), + "ko" to key(R.string.key_app_language_ko), + "ru" to key(R.string.key_app_language_ru), + "ar" to key(R.string.key_app_language_ar), + ), + key(R.string.key_launcher_icon) to mapOf( + "adaptive" to key(R.string.key_launcher_icon_adaptive), + "transparent_background" to key(R.string.key_launcher_icon_transparent_background), + ), + key(R.string.key_editor_pinch_to_zoom_strategy) to mapOf( + "change_text_size" to key(R.string.key_editor_pinch_to_zoom_change_text_size), + "scale_view" to key(R.string.key_editor_pinch_to_zoom_scale_view), + "disable" to key(R.string.key_editor_pinch_to_zoom_disable), + ), + key(R.string.key_root_record_out_file_type) to mapOf( + "binary" to key(R.string.key_root_record_out_file_type_binary), + "js" to key(R.string.key_root_record_out_file_type_js), + ), + ) + } + + data class Result( + val success: Boolean, + val appliedCount: Int, + val skippedCount: Int, + val appliedKeys: List, + val failedKeys: List, + val errors: List, + val message: String, + ) { + fun toJson(): JSONObject = JSONObject().apply { + put("success", this@Result.success) + put("applied_count", this@Result.appliedCount) + put("skipped_count", this@Result.skippedCount) + put("applied_keys", JSONArray(this@Result.appliedKeys)) + put("failed_keys", JSONArray(this@Result.failedKeys)) + put("errors", JSONArray(this@Result.errors)) + put("message", this@Result.message) + } + + fun toLogLine(): String { + val ts = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + }.format(java.util.Date()) + val level = when { + success -> "INFO " + appliedCount > 0 -> "WARN " + else -> "ERROR" + } + val sb = StringBuilder().apply { + append(ts).append(' ').append(level).append(" fleet_profile: applied=").append(appliedCount) + append(" skipped=").append(skippedCount) + if (appliedKeys.isNotEmpty()) append(" keys=").append(appliedKeys.joinToString(",")) + if (failedKeys.isNotEmpty()) append(" failed=").append(failedKeys.joinToString(",")) + append(" errors=").append(errors.size) + append(" message=").append(quote(message)) + } + return sb.toString() + } + + private fun quote(s: String): String = "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\"" + } + + /** + * Apply a fleet profile from a JSON string, using the default SharedPreferences. + */ + @JvmStatic + fun applyJson(context: Context, json: String): Result { + return try { + val profile = JSONObject(json) + applyProfile(Pref.get(), profile) + } catch (e: Exception) { + Result( + success = false, appliedCount = 0, skippedCount = 0, + appliedKeys = emptyList(), failedKeys = emptyList(), + errors = listOf(e.message ?: "Invalid JSON"), + message = "Profile parse failed: ${e.message}" + ) + } + } + + /** + * Apply a fleet profile from a JSON object to the given SharedPreferences. + * Visible for testing, so callers can provide a dedicated SharedPreferences instance. + */ + @VisibleForTesting + @JvmStatic + fun applyJson(context: Context, json: String, prefs: SharedPreferences): Result { + return try { + applyProfile(prefs, JSONObject(json)) + } catch (e: Exception) { + Result( + success = false, appliedCount = 0, skippedCount = 0, + appliedKeys = emptyList(), failedKeys = emptyList(), + errors = listOf(e.message ?: "Invalid JSON"), + message = "Profile parse failed: ${e.message}" + ) + } + } + + /** + * Apply a fleet profile read from a local file path. + */ + @JvmStatic + fun applyFromPath(context: Context, path: String): Result { + return try { + val json = File(path).readText(Charsets.UTF_8) + applyJson(context, json) + } catch (e: Exception) { + Result( + success = false, appliedCount = 0, skippedCount = 0, + appliedKeys = emptyList(), failedKeys = emptyList(), + errors = listOf(e.message ?: "Read error"), + message = "Failed to read $path: ${e.message}" + ) + } + } + + /** + * Apply a fleet profile read from a content URI. + */ + @JvmStatic + fun applyFromUri(context: Context, uri: Uri): Result { + return try { + val stream = context.contentResolver.openInputStream(uri) + ?: return Result( + success = false, appliedCount = 0, skippedCount = 0, + appliedKeys = emptyList(), failedKeys = emptyList(), + errors = listOf("Cannot open URI"), + message = "Cannot open URI: $uri" + ) + val json = stream.use { it.reader(Charsets.UTF_8).readText() } + applyJson(context, json) + } catch (e: Exception) { + Result( + success = false, appliedCount = 0, skippedCount = 0, + appliedKeys = emptyList(), failedKeys = emptyList(), + errors = listOf(e.message ?: "URI error"), + message = "Failed to read URI $uri: ${e.message}" + ) + } + } + + private fun applyProfile(prefs: SharedPreferences, profile: JSONObject): Result { + val errors = mutableListOf() + val appliedKeys = mutableListOf() + val failedKeys = mutableListOf() + val meta = profile.optJSONObject("_meta") + // clear_existing wipes ALL SharedPreferences before applying. Use with care. + val clearExisting = meta?.optBoolean("clear_existing", false) ?: false + + prefs.edit(commit = true) { + if (clearExisting) { + clear() + } + + val keys = profile.keys() + while (keys.hasNext()) { + val rawKey = keys.next() + if (rawKey.startsWith("_")) { + continue + } + val prefKey = resolveKey(rawKey) + if (prefKey == null) { + failedKeys.add(rawKey) + errors.add("Unknown key: $rawKey") + continue + } + + val value = profile.get(rawKey) + try { + val resolvedValue = resolveValue(prefKey, value) + putValue(this, prefKey, resolvedValue) + appliedKeys.add(rawKey) + } catch (e: Exception) { + failedKeys.add(rawKey) + errors.add("$rawKey: ${e.message}") + } + } + } + + val message = "Applied ${appliedKeys.size} preferences, skipped ${failedKeys.size}" + + if (errors.isEmpty()) "" else " (${errors.size} errors)" + + return Result( + success = errors.isEmpty(), + appliedCount = appliedKeys.size, + skippedCount = failedKeys.size, + appliedKeys = appliedKeys, + failedKeys = failedKeys, + errors = errors, + message = message, + ) + } + + private fun resolveKey(rawKey: String): String? { + if (rawKey.startsWith(KEY_PREFIX)) { + return rawKey + } + return aliasToKey[rawKey] + } + + private fun resolveValue(prefKey: String, value: Any?): Any? { + if (value !is String) { + return value + } + val valueAliases = valueAliasToKey[prefKey] ?: return value + return valueAliases[value] ?: run { + Log.w("FleetProfile", "Unrecognized value '$value' for key '$prefKey'; passing through as-is") + value + } + } + + private fun putValue(editor: SharedPreferences.Editor, key: String, value: Any?) { + when (value) { + is Boolean -> editor.putBoolean(key, value) + is String -> editor.putString(key, value) + is Int -> editor.putInt(key, value) + is Long -> editor.putLong(key, value) + is Double -> editor.putFloat(key, value.toFloat()) + is Float -> editor.putFloat(key, value) + is JSONArray -> { + val set = LinkedHashSet() + for (i in 0 until value.length()) { + set.add(value.getString(i)) + } + editor.putStringSet(key, set) + } + else -> throw IllegalArgumentException("Unsupported type: ${value?.javaClass?.simpleName}") + } + } + +} diff --git a/docs/FLEET_PROFILE.md b/docs/FLEET_PROFILE.md new file mode 100644 index 000000000..5d7124ca6 --- /dev/null +++ b/docs/FLEET_PROFILE.md @@ -0,0 +1,170 @@ +# Fleet / Headless Configuration Profiles + +AutoJs6 6.8+ supports applying a JSON configuration profile without opening the +app UI. This is intended for fleet/headless deployments (e.g. Termux + Shizuku + +wireless ADB) where the initial drawer toggles and settings would otherwise +require fragile UI automation. + +## Quick start + +1. Push a profile JSON to the device: + + ```bash + adb push fleet_profile.json /sdcard/Download/autojs6-fleet.json + ``` + +2. Apply it via `am start`: + + ```bash + adb shell am start -a org.autojs.autojs6.action.APPLY_FLEET_PROFILE \ + -e profile_path /sdcard/Download/autojs6-fleet.json \ + org.autojs.autojs.core.pref.fleet.FleetProfileActivity + ``` + + Or with a content URI: + + ```bash + adb shell am start -a org.autojs.autojs6.action.APPLY_FLEET_PROFILE \ + -d file:///sdcard/Download/autojs6-fleet.json \ + org.autojs.autojs.core.pref.fleet.FleetProfileActivity + ``` + +3. The activity applies the preferences silently (no Toast) and exits. + Result is delivered four ways (all fire on every invocation): + + **a) Activity result intent** — for `startActivityForResult` callers: + + | Extra | Type | Description | + |-------|------|-------------| + | `result_success` | `boolean` | Whether all keys were applied | + | `result_applied_count` | `int` | Number of keys successfully written | + | `result_skipped_count` | `int` | Number of keys skipped (unknown or error) | + | `result_applied_keys` | `String[]` | Key aliases that were written | + | `result_failed_keys` | `String[]` | Key aliases that could not be written | + | `result_errors` | `String[]` | Human-readable error messages | + | `result_message` | `String` | Summary string | + + **b) Broadcast** — action `org.autojs.autojs6.action.FLEET_PROFILE_RESULT` + with the same extras as above. Any app with a registered receiver can + listen. + + **c) JSON result file** — written to the path specified by `-e result_path`, + or by default alongside the profile file (e.g. + `/sdcard/Download/autojs6-fleet-result.json`), or falling back to + `/sdcard/autojs6-fleet-result.json`. The file contains: + + ```json + { + "success": true, + "applied_count": 12, + "skipped_count": 0, + "applied_keys": ["foreground_service", "stable_mode", ...], + "failed_keys": [], + "errors": [], + "message": "Applied 12 preferences, skipped 0" + } + ``` + + **d) Daily rotating log** — each invocation appends a JSON line to + `/sdcard/autojs6-fleet-YYYY-MM-DD.log`. The date in the filename changes + at midnight, so the log restarts each day naturally. Each line is a + Unix-style log entry: + + ``` + 2026-07-11T22:15:30Z INFO fleet_profile: applied=12 skipped=0 keys=foreground_service,stable_mode errors=0 message="Applied 12 preferences, skipped 0" + 2026-07-11T22:16:00Z ERROR fleet_profile: applied=0 skipped=1 failed=bad_key errors=1 message="Applied 0 preferences, skipped 1 (1 errors)" + ``` + + Level is `INFO` (all success), `WARN` (partial), or `ERROR` (failure). + +## Profile format + +Profiles are plain JSON objects. Each key maps to an AutoJs6 preference. The +`_meta` section is optional and controls apply behavior. + +```json +{ + "_meta": { + "name": "My fleet profile", + "version": 1, + "clear_existing": false + }, + "foreground_service": true, + "floating_menu_shown": false, + "enable_a11y_service_with_secure_settings": true, + "stable_mode": true, + "guard_mode": true, + "restart_strategy": "quick", + "auto_check_for_updates": false +} +``` + +### Supported value types + +| Type | SharedPreferences method | Example | +|--------|--------------------------|---------| +| bool | `putBoolean` | `true` | +| string | `putString` | `"quick"` | +| int | `putInt` | `350` | +| long | `putLong` | `1234567890` | +| float | `putFloat` | `0.5` | +| array | `putStringSet` | `["a", "b"]` | + +### Key aliases + +You can use short aliases instead of the raw `key_$_...` preference keys. +Common aliases: + +| Alias | Maps to | +|-------|---------| +| `foreground_service` | `key_$_foreground_service` | +| `floating_menu_shown` | `key_$_floating_menu_shown` | +| `enable_a11y_service_with_secure_settings` | `key_$_enable_a11y_service_with_secure_settings` | +| `enable_a11y_service_with_root_access` | `key_$_enable_a11y_service_with_root_access` | +| `stable_mode` | `key_$_stable_mode` | +| `guard_mode` | `key_$_guard_mode` | +| `use_volume_control_running` | `key_$_use_volume_control_running` | +| `use_volume_control_record` | `key_$_use_volume_control_record` | +| `record_toast` | `key_$_record_toast` | +| `auto_check_for_updates` | `key_$_auto_check_for_updates` | +| `restart_strategy` | `key_$_restart_strategy` | +| `scheduled_restart_backend` | `key_$_scheduled_restart_backend` | +| `timed_task_backend` | `key_$_timed_task_backend` | +| `night_mode` | `key_$_night_mode` | +| `root_mode` | `key_$_root_mode` | +| `file_extensions` | `key_$_file_extensions` | +| `hidden_files` | `key_$_hidden_files` | +| `display_over_other_apps` | `key_$_display_over_other_apps` | +| `post_notifications_permission` | `key_$_post_notifications_permission` | +| `all_files_access` | `key_$_all_files_access` | +| `keep_screen_on_when_in_foreground` | `key_$_keep_screen_on_when_in_foreground` | + +Raw keys (`key_$_...`) are also accepted for keys not in the alias table. + +## Security notes + +- `FleetProfileActivity` is exported because provisioning tools run outside the + app. Any app with `START_FOREGROUND_SERVICES_FROM_BACKGROUND` or that can + start activities can trigger it. +- Profiles should only be placed in locations your provisioning tooling + controls. +- This API only writes AutoJs6's own SharedPreferences; it does **not** grant + Android runtime permissions. Use `pm grant` / Shizuku / `appops` for those. + +## Default profile + +A reference profile for unattended watchdog use is bundled at +`app/src/main/assets/fleet_profile_default.json`. + +## Limitations + +- The profile only writes preferences. Some settings still require a one-time + Android permission grant (e.g. `WRITE_SECURE_SETTINGS` for accessibility + via `secure settings`, `MANAGE_EXTERNAL_STORAGE`, `BIND_NOTIFICATION_LISTENER_SERVICE`). +- Runtime service effects are triggered on the next natural lifecycle event. + Use `am startservice` or `am start` on AutoJs6 if you need an immediate restart. + +## See also + +- [Issue #553](https://github.com/SuperMonster003/AutoJs6/issues/553) +- [stayturgid](https://github.com/djbclark/stayturgid) — example fleet orchestration From 25b906e72040f4e0ead1639c190e60dba35108e0 Mon Sep 17 00:00:00 2001 From: Daniel JB Clark Date: Tue, 14 Jul 2026 00:42:24 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20security=20=E2=80=94=20safeFile=20gu?= =?UTF-8?q?ard,=20canonicalPath=20traversal,=20clear=5Fexisting=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/pref/fleet/FleetProfileActivity.kt | 29 +++++++++++++++++-- .../core/pref/fleet/FleetProfileApplier.kt | 5 +++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileActivity.kt b/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileActivity.kt index cebe766c3..346137eb5 100644 --- a/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileActivity.kt +++ b/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileActivity.kt @@ -49,6 +49,11 @@ import java.util.Locale * can call it before the user opens the app. Profile files should be * placed on shared storage; AutoJs6 must have READ_EXTERNAL_STORAGE or * MANAGE_EXTERNAL_STORAGE as needed. + * + * Security: this activity is exported with no caller permission check so + * any app can trigger it. It only writes AutoJs6's own SharedPreferences + * (not system settings) and writes result files constrained to the external + * storage directory. Profiles should come from trusted sources. */ class FleetProfileActivity : Activity() { @@ -141,12 +146,12 @@ class FleetProfileActivity : Activity() { } private fun resolveResultFile(): File { - intent.getStringExtra(EXTRA_RESULT_PATH)?.let { return File(EnvironmentUtils.normalizePath(it) ?: it) } + intent.getStringExtra(EXTRA_RESULT_PATH)?.let { return safeFile(it) } intent.getStringExtra(EXTRA_PROFILE_PATH)?.let { path -> val normalized = EnvironmentUtils.normalizePath(path) ?: path val parent = File(normalized).parentFile if (parent != null && parent.exists()) { - return File(parent, DEFAULT_RESULT_FILENAME) + return safeFile(File(parent, DEFAULT_RESULT_FILENAME).absolutePath) } } return File( @@ -155,4 +160,24 @@ class FleetProfileActivity : Activity() { ) } + /** + * Resolve a path to a safe File within external storage. + * Falls back to external storage root if the path escapes the sandbox. + */ + private fun safeFile(path: String): File { + val normalized = EnvironmentUtils.normalizePath(path) ?: path + val safeDir = try { + val canonical = File(normalized).canonicalPath + if (canonical.startsWith(EnvironmentUtils.externalStoragePath + "/") + || canonical == EnvironmentUtils.externalStoragePath) { + File(canonical) + } else { + null + } + } catch (_: Exception) { + null + } + return safeDir ?: File(EnvironmentUtils.externalStorageDirectory, DEFAULT_RESULT_FILENAME) + } + } diff --git a/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplier.kt b/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplier.kt index 3de247423..45fbf0db3 100644 --- a/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplier.kt +++ b/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplier.kt @@ -294,8 +294,11 @@ object FleetProfileApplier { val appliedKeys = mutableListOf() val failedKeys = mutableListOf() val meta = profile.optJSONObject("_meta") - // clear_existing wipes ALL SharedPreferences before applying. Use with care. + // clear_existing wipes ALL SharedPreferences before applying. Use with extreme care. val clearExisting = meta?.optBoolean("clear_existing", false) ?: false + if (clearExisting) { + Log.w("FleetProfile", "clear_existing is true — wiping ALL preferences before applying") + } prefs.edit(commit = true) { if (clearExisting) {