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 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 safeFile(File(parent, DEFAULT_RESULT_FILENAME).absolutePath)
+ }
+ }
+ return File(
+ EnvironmentUtils.externalStorageDirectory,
+ DEFAULT_RESULT_FILENAME
+ )
+ }
+
+ /**
+ * 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
new file mode 100644
index 000000000..45fbf0db3
--- /dev/null
+++ b/app/src/main/java/org/autojs/autojs/core/pref/fleet/FleetProfileApplier.kt
@@ -0,0 +1,384 @@
+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 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) {
+ 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