From 995c75dbb8c6c2876f833782001cfb00bc4beb1f Mon Sep 17 00:00:00 2001 From: Emirhan Date: Mon, 14 Sep 2026 23:45:42 +0300 Subject: [PATCH 1/6] fix: drive background notifications and channels from persisted locale (#23) --- .../java/com/flowpilot/app/FlowPilotApp.kt | 4 + .../app/data/AutomationRepository.kt | 37 +++++++++ .../flowpilot/app/engine/AutomationService.kt | 76 ++++++++++++++++--- .../app/ui/util/AutomationNameGenerator.kt | 10 --- .../com/flowpilot/app/ui/util/Localization.kt | 70 ++++++++++++----- .../app/widget/FlowPilotWidgetProvider.kt | 11 ++- .../app/ui/util/LocaleSelectionTest.kt | 39 ++++++++++ docs/IMPLEMENTATION.md | 2 +- docs/STATUS.md | 1 + scripts/test_lint_resource_contracts.py | 8 +- scripts/test_locale_contracts.py | 65 ++++++++++++++++ 11 files changed, 274 insertions(+), 49 deletions(-) create mode 100644 app/src/test/java/com/flowpilot/app/ui/util/LocaleSelectionTest.kt create mode 100644 scripts/test_locale_contracts.py diff --git a/app/src/main/java/com/flowpilot/app/FlowPilotApp.kt b/app/src/main/java/com/flowpilot/app/FlowPilotApp.kt index 5e61672..8d21801 100644 --- a/app/src/main/java/com/flowpilot/app/FlowPilotApp.kt +++ b/app/src/main/java/com/flowpilot/app/FlowPilotApp.kt @@ -3,6 +3,7 @@ package com.flowpilot.app import android.app.Application import com.flowpilot.app.actions.ShizukuShell import com.flowpilot.app.data.AutomationRepository +import com.flowpilot.app.ui.util.applyAppLocale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -14,8 +15,11 @@ class FlowPilotApp : Application() { override fun onCreate() { super.onCreate() ShizukuShell.instance.init(this) + val initialLanguage = AutomationRepository.getPersistedLanguage(this) + applyAppLocale(this, initialLanguage) applicationScope.launch { AutomationRepository(this@FlowPilotApp).migrateLegacySecretsIfNeeded() + AutomationRepository(this@FlowPilotApp).syncPersistedLanguage() } } } diff --git a/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt b/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt index f3a3dbe..6c34f2d 100644 --- a/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt +++ b/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt @@ -11,13 +11,16 @@ import androidx.datastore.preferences.preferencesDataStore import com.flowpilot.app.data.model.Automation import com.flowpilot.app.data.model.ExecutionHistoryEntry import com.flowpilot.app.data.security.SecretCipher +import com.flowpilot.app.engine.AutomationService import com.flowpilot.app.engine.GeofenceTransition import com.flowpilot.app.engine.GeofenceDiagnostic import com.flowpilot.app.engine.GeofenceDiagnosticStatus import com.flowpilot.app.engine.GeofenceEvent +import com.flowpilot.app.ui.util.applyAppLocale import com.flowpilot.app.ui.util.automaticAutomationName import com.flowpilot.app.ui.util.localizedForAppLanguage import java.util.UUID +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -70,10 +73,19 @@ class AutomationRepository(private val context: Context) { .associateBy { it.automationId } } + suspend fun syncPersistedLanguage(): String { + val lang = appLanguage.first() + persistLanguage(context, lang) + return lang + } + suspend fun setAppLanguage(language: String) { + persistLanguage(context, language) context.dataStore.edit { prefs -> prefs[languageKey] = language } + applyAppLocale(context, language) + AutomationService.refreshNotificationLocale(context) } val appTheme: Flow = context.dataStore.data.map { prefs -> @@ -835,6 +847,31 @@ class AutomationRepository(private val context: Context) { private const val MAX_GEOFENCE_ERROR_LENGTH = 300 private const val EXECUTION_LEASE_MS = 10 * 60_000L private val executionStateMutex = Mutex() + const val PREFS_LOCALE = "automation_locale_prefs" + const val KEY_APP_LANGUAGE = "app_language" + + fun getPersistedLanguage(context: Context): String { + val prefs = context.getSharedPreferences(PREFS_LOCALE, Context.MODE_PRIVATE) + val cached = prefs.getString(KEY_APP_LANGUAGE, null) + if (cached != null) return cached + + val fromDataStore = runCatching { + kotlinx.coroutines.runBlocking(Dispatchers.IO) { + AutomationRepository(context).appLanguage.first() + } + }.getOrNull() + + val language = fromDataStore ?: "system" + persistLanguage(context, language) + return language + } + + fun persistLanguage(context: Context, language: String) { + context.getSharedPreferences(PREFS_LOCALE, Context.MODE_PRIVATE) + .edit() + .putString(KEY_APP_LANGUAGE, language) + .apply() + } } suspend fun migrateLegacySecretsIfNeeded() { diff --git a/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt b/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt index a148146..42acfff 100644 --- a/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt +++ b/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt @@ -15,6 +15,7 @@ import android.util.Log import com.flowpilot.app.MainActivity import com.flowpilot.app.R import com.flowpilot.app.data.AutomationRepository +import com.flowpilot.app.ui.util.selectedLocaleContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -25,6 +26,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -49,6 +51,13 @@ class AutomationService : Service() { stopSelf() return } + lifecycleScope.launch { + AutomationRepository(applicationContext).appLanguage + .drop(1) + .collect { + refreshNotificationLocale(this@AutomationService) + } + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -120,13 +129,14 @@ class AutomationService : Service() { } private fun createChannel() { - val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val localeContext = selectedLocaleContext() + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return val channel = NotificationChannel( CHANNEL_ID, - getString(R.string.notif_channel_engine), + localeContext.getString(R.string.notif_channel_engine), NotificationManager.IMPORTANCE_MIN, ).apply { - description = getString(R.string.notif_channel_engine_desc) + description = localeContext.getString(R.string.notif_channel_engine_desc) setShowBadge(false) setSound(null, AudioAttributes.Builder().build()) enableVibration(false) @@ -159,6 +169,7 @@ class AutomationService : Service() { } private fun buildNotification(): Notification { + val localeContext = selectedLocaleContext() val openIntent = Intent(this, MainActivity::class.java) val pi = PendingIntent.getActivity( this, 0, openIntent, @@ -171,8 +182,8 @@ class AutomationService : Service() { Notification.Builder(this) } return builder - .setContentTitle(getString(R.string.notif_engine_title)) - .setContentText(getString(R.string.notif_engine_text)) + .setContentTitle(localeContext.getString(R.string.notif_engine_title)) + .setContentText(localeContext.getString(R.string.notif_engine_text)) .setSmallIcon(R.mipmap.ic_launcher) .setContentIntent(pi) .setOngoing(true) @@ -291,14 +302,15 @@ class AutomationService : Service() { private fun ensureFailureChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val localeContext = context.selectedLocaleContext() + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return manager.createNotificationChannel( NotificationChannel( FAILURE_CHANNEL_ID, - context.getString(R.string.notif_channel_engine_failure), + localeContext.getString(R.string.notif_channel_engine_failure), NotificationManager.IMPORTANCE_DEFAULT, ).apply { - description = context.getString(R.string.notif_channel_engine_failure_desc) + description = localeContext.getString(R.string.notif_channel_engine_failure_desc) }, ) } @@ -316,6 +328,7 @@ class AutomationService : Service() { com.flowpilot.app.widget.FlowPilotWidgetProvider.updateAllWidgets(context) try { ensureFailureChannel(context) + val localeContext = context.selectedLocaleContext() val openIntent = Intent(context, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( context, 1, openIntent, @@ -328,20 +341,59 @@ class AutomationService : Service() { Notification.Builder(context) } val notification = builder - .setContentTitle(context.getString(R.string.notif_engine_failure_title)) - .setContentText(context.getString(R.string.notif_engine_failure_text)) + .setContentTitle(localeContext.getString(R.string.notif_engine_failure_title)) + .setContentText(localeContext.getString(R.string.notif_engine_failure_text)) .setSmallIcon(R.mipmap.ic_launcher) .setContentIntent(pendingIntent) .setAutoCancel(true) .setCategory(Notification.CATEGORY_ERROR) .build() - (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) - .notify(FAILURE_NOTIF_ID, notification) + (context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager) + ?.notify(FAILURE_NOTIF_ID, notification) } catch (_: Exception) { Log.w("AutomationService", "Failure notification unavailable") } } + fun refreshNotificationLocale(context: Context) { + val service = activeService + if (service != null) { + service.createChannel() + try { + val nm = service.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + nm?.notify(NOTIF_ID, service.buildNotification()) + } catch (e: Exception) { + Log.w("AutomationService", "Failed to update foreground notification on locale switch", e) + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val localeContext = context.selectedLocaleContext() + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + val channel = NotificationChannel( + CHANNEL_ID, + localeContext.getString(R.string.notif_channel_engine), + NotificationManager.IMPORTANCE_MIN, + ).apply { + description = localeContext.getString(R.string.notif_channel_engine_desc) + setShowBadge(false) + setSound(null, AudioAttributes.Builder().build()) + enableVibration(false) + lockscreenVisibility = Notification.VISIBILITY_SECRET + } + nm?.createNotificationChannel(channel) + } + ensureFailureChannel(context) + } + + val hasFailure = mutableFailure.value || + context.getSharedPreferences(STATUS_PREFS, Context.MODE_PRIVATE).contains(STARTUP_FAILURE_KEY) + if (hasFailure) { + reportStartupFailure(context) + } + + com.flowpilot.app.widget.FlowPilotWidgetProvider.updateAllWidgets(context) + } + private fun start(context: Context): Boolean { return try { val intent = Intent(context, AutomationService::class.java) diff --git a/app/src/main/java/com/flowpilot/app/ui/util/AutomationNameGenerator.kt b/app/src/main/java/com/flowpilot/app/ui/util/AutomationNameGenerator.kt index d5950a2..81d9e4d 100644 --- a/app/src/main/java/com/flowpilot/app/ui/util/AutomationNameGenerator.kt +++ b/app/src/main/java/com/flowpilot/app/ui/util/AutomationNameGenerator.kt @@ -1,20 +1,10 @@ package com.flowpilot.app.ui.util import android.content.Context -import android.content.res.Configuration import com.flowpilot.app.R import com.flowpilot.app.data.model.ActionType import com.flowpilot.app.data.model.TriggerEvent -fun Context.localizedForAppLanguage(language: String): Context { - val locale = when (language.lowercase()) { - "tr" -> java.util.Locale.forLanguageTag("tr") - "en" -> java.util.Locale.forLanguageTag("en") - else -> return this - } - return createConfigurationContext(Configuration(resources.configuration).apply { setLocale(locale) }) -} - fun automaticAutomationName( context: Context, trigger: TriggerEvent, diff --git a/app/src/main/java/com/flowpilot/app/ui/util/Localization.kt b/app/src/main/java/com/flowpilot/app/ui/util/Localization.kt index c43da03..2cc91fe 100644 --- a/app/src/main/java/com/flowpilot/app/ui/util/Localization.kt +++ b/app/src/main/java/com/flowpilot/app/ui/util/Localization.kt @@ -372,6 +372,56 @@ class LocalizedContextWrapper( ?: error("No SavedStateRegistry found in base context") } +fun resolveLocaleLanguage(language: String?): String = when (language?.lowercase()) { + "tr" -> "tr" + "en" -> "en" + else -> "system" +} + +fun targetLocaleForLanguage( + language: String, + defaultLocale: java.util.Locale = java.util.Locale.getDefault(), +): java.util.Locale = when (resolveLocaleLanguage(language)) { + "tr" -> java.util.Locale.forLanguageTag("tr") + "en" -> java.util.Locale.forLanguageTag("en") + else -> defaultLocale +} + +fun applyAppLocale(context: android.content.Context, language: String) { + val targetTag = resolveLocaleLanguage(language) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + val localeManager = context.getSystemService(android.app.LocaleManager::class.java) + val desiredList = when (targetTag) { + "tr" -> android.os.LocaleList.forLanguageTags("tr") + "en" -> android.os.LocaleList.forLanguageTags("en") + else -> android.os.LocaleList.getEmptyLocaleList() + } + try { + if (localeManager != null && localeManager.applicationLocales != desiredList) { + localeManager.applicationLocales = desiredList + } + } catch (_: Throwable) {} + } + when (targetTag) { + "tr" -> java.util.Locale.setDefault(java.util.Locale.forLanguageTag("tr")) + "en" -> java.util.Locale.setDefault(java.util.Locale.forLanguageTag("en")) + } +} + +fun android.content.Context.localizedForAppLanguage(language: String): android.content.Context { + val targetTag = resolveLocaleLanguage(language) + if (targetTag == "system") return this + val locale = targetLocaleForLanguage(targetTag) + return createConfigurationContext(android.content.res.Configuration(resources.configuration).apply { + setLocale(locale) + }) +} + +fun android.content.Context.selectedLocaleContext(): android.content.Context { + val language = com.flowpilot.app.data.AutomationRepository.getPersistedLanguage(this) + return localizedForAppLanguage(language) +} + @Composable fun AppLocaleProvider( language: String, @@ -383,11 +433,7 @@ fun AppLocaleProvider( ?: (context as? androidx.activity.result.ActivityResultRegistryOwner) val targetLocale = androidx.compose.runtime.remember(language) { - when (language.lowercase()) { - "tr" -> java.util.Locale.forLanguageTag("tr") - "en" -> java.util.Locale.forLanguageTag("en") - else -> java.util.Locale.getDefault() - } + targetLocaleForLanguage(language) } val localizedConfiguration = androidx.compose.runtime.remember(configuration, targetLocale) { @@ -401,19 +447,7 @@ fun AppLocaleProvider( } androidx.compose.runtime.LaunchedEffect(language) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - val localeManager = context.getSystemService(android.app.LocaleManager::class.java) - val desiredList = when (language.lowercase()) { - "tr" -> android.os.LocaleList.forLanguageTags("tr") - "en" -> android.os.LocaleList.forLanguageTags("en") - else -> android.os.LocaleList.getEmptyLocaleList() - } - try { - if (localeManager != null && localeManager.applicationLocales != desiredList) { - localeManager.applicationLocales = desiredList - } - } catch (_: Throwable) {} - } + applyAppLocale(context, language) } if (activityResultRegistryOwner != null) { diff --git a/app/src/main/java/com/flowpilot/app/widget/FlowPilotWidgetProvider.kt b/app/src/main/java/com/flowpilot/app/widget/FlowPilotWidgetProvider.kt index a196a86..1d71f0a 100644 --- a/app/src/main/java/com/flowpilot/app/widget/FlowPilotWidgetProvider.kt +++ b/app/src/main/java/com/flowpilot/app/widget/FlowPilotWidgetProvider.kt @@ -11,6 +11,7 @@ import com.flowpilot.app.MainActivity import com.flowpilot.app.R import com.flowpilot.app.data.AutomationRepository import com.flowpilot.app.engine.AutomationService +import com.flowpilot.app.ui.util.selectedLocaleContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -64,24 +65,26 @@ class FlowPilotWidgetProvider : AppWidgetProvider() { ) views.setOnClickPendingIntent(R.id.widget_btn_toggle, togglePendingIntent) + val localeContext = context.selectedLocaleContext() + // Update UI state if (running) { views.setImageViewResource(R.id.widget_status_dot, R.drawable.bg_widget_circle_active) val statusText = if (totalCount > 0) { - context.getString(R.string.widget_active_count, activeCount, totalCount) + localeContext.getString(R.string.widget_active_count, activeCount, totalCount) } else { - context.getString(R.string.widget_engine_active) + localeContext.getString(R.string.widget_engine_active) } views.setTextViewText(R.id.widget_status_text, statusText) views.setImageViewResource(R.id.widget_btn_toggle, R.drawable.ic_widget_pause) } else { views.setImageViewResource(R.id.widget_status_dot, R.drawable.bg_widget_circle_paused) - views.setTextViewText(R.id.widget_status_text, context.getString(R.string.widget_engine_paused)) + views.setTextViewText(R.id.widget_status_text, localeContext.getString(R.string.widget_engine_paused)) views.setImageViewResource(R.id.widget_btn_toggle, R.drawable.ic_widget_play) } if (failed || (isEngineEnabled && !running)) { - views.setTextViewText(R.id.widget_status_text, context.getString( + views.setTextViewText(R.id.widget_status_text, localeContext.getString( if (failed) R.string.notif_engine_failure_title else R.string.engine_not_running, )) } diff --git a/app/src/test/java/com/flowpilot/app/ui/util/LocaleSelectionTest.kt b/app/src/test/java/com/flowpilot/app/ui/util/LocaleSelectionTest.kt new file mode 100644 index 0000000..a3ae778 --- /dev/null +++ b/app/src/test/java/com/flowpilot/app/ui/util/LocaleSelectionTest.kt @@ -0,0 +1,39 @@ +package com.flowpilot.app.ui.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Locale + +class LocaleSelectionTest { + + @Test + fun `resolveLocaleLanguage maps supported language tags and defaults cleanly`() { + assertEquals("tr", resolveLocaleLanguage("tr")) + assertEquals("tr", resolveLocaleLanguage("TR")) + assertEquals("tr", resolveLocaleLanguage("Tr")) + assertEquals("en", resolveLocaleLanguage("en")) + assertEquals("en", resolveLocaleLanguage("EN")) + assertEquals("system", resolveLocaleLanguage("system")) + assertEquals("system", resolveLocaleLanguage(null)) + assertEquals("system", resolveLocaleLanguage("")) + assertEquals("system", resolveLocaleLanguage("de")) + assertEquals("system", resolveLocaleLanguage("fr")) + } + + @Test + fun `targetLocaleForLanguage returns explicit locale for supported tags and fallback for others`() { + val fallback = Locale("en", "US") + + val trLocale = targetLocaleForLanguage("tr", fallback) + assertEquals("tr", trLocale.language) + + val enLocale = targetLocaleForLanguage("en", fallback) + assertEquals("en", enLocale.language) + + val systemLocale = targetLocaleForLanguage("system", fallback) + assertEquals(fallback, systemLocale) + + val unknownLocale = targetLocaleForLanguage("unknown", fallback) + assertEquals(fallback, unknownLocale) + } +} diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index c9f3f29..766bbf3 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -205,7 +205,7 @@ Manual test runs execute a saved rule's effective actions on `Dispatchers.IO`, b Geofence prerequisite and registration failures retry with bounded exponential backoff (10 seconds base, 60 seconds maximum); desired configuration or prerequisite changes trigger an immediate retry. This prevents rapid retry, DataStore, and UI loops while keeping recovery responsive. -Execution history persists `ActionResultCode` plus safe arguments for new records, separating locale-neutral outcome data from display text. History renders known success, state, notification, SMS, permission, and cancellation results through current app-language resources. SMS arguments are masked before persistence; technical failures retain only redacted fallback text. Legacy successful records resolve from their action type for localization. Blank automatic rule names use the saved English or Turkish app language; custom names remain untouched. +Execution history persists `ActionResultCode` plus safe arguments for new records, separating locale-neutral outcome data from display text. History renders known success, state, notification, SMS, permission, and cancellation results through current app-language resources. SMS arguments are masked before persistence; technical failures retain only redacted fallback text. Legacy successful records resolve from their action type for localization. Blank automatic rule names use the saved English or Turkish app language; custom names remain untouched. Persisted app language also drives background components (foreground engine notification, notification channels, and startup-failure alerts) across service restarts, task removals, process recreation, and boot. Switching language refreshes ongoing notification text and channel metadata immediately. Normal JSON export/share removes webhook URL, headers, and body; normal imports disable rules. Encrypted full backup uses a portable AES-256-GCM envelope with PBKDF2-HMAC-SHA256 (100,000 iterations), random 16-byte salt, random 12-byte IV, and a six-character minimum password. It preserves full rule data, including secret fields and enabled state. Format/version/KDF bounds, password, and GCM authentication are verified before import mutation; imported secrets are re-encrypted with the target device's Android Keystore. Execution history, geofence queue/diagnostics, engine state, Android permissions, Shizuku state, and TTS cache are excluded. diff --git a/docs/STATUS.md b/docs/STATUS.md index e296416..3b82ec9 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,6 +12,7 @@ Last updated: 2026-09-14 - Action reordering, live location fetcher, Automation Presets, and Geofencing unit tests (`AutomationRepositoryGeofenceQueueTest`, `GeofenceConfigValidationTest`, `GeofenceDiffTest`, `GeofencePrerequisitesTest`, `LocationDependencyTest`, `RuleEvaluatorGeofenceTest`) implemented and verified. - Encrypted backup unit coverage verifies full-secret round trips, enabled-state preservation, plaintext non-leakage, wrong-password/tamper rejection, format/version/KDF bounds, single-rule backup, normal-export regression, and cross-device Android Keystore re-encryption. - History localization unit coverage verifies locale-neutral outcome records, masked SMS result arguments, legacy successful outcome mapping, technical failure fallback, and Turkish automatic rule-name generation. +- Service locale and notification refresh contract verified: persisted app locale drives foreground engine and startup-failure notification channels and text across boot, service restart, process recreation, and task removal; language switch refreshes active notifications and channel metadata immediately (#23). - Conflict analyzer and pre-save/pre-enable warning implemented: runtime-aligned trigger overlap, opposing state-action matrix, likely/possible confidence, conflict rule inspection with pending-state restoration, and deliberate non-blocking override. GitHub `Build & Test` passed; physical-device validation remains pending. - GitHub Pages site modularized: split single monolithic `docs/index.html` into external stylesheet (`docs/assets/css/style.css`) and script (`docs/assets/js/app.js`), unified brand favicon (`docs/assets/favicon.svg`), converted brand into accessible home link, compacted desktop footer, and added mobile-first responsive pass (#2, #3). diff --git a/scripts/test_lint_resource_contracts.py b/scripts/test_lint_resource_contracts.py index 545fc24..841b398 100644 --- a/scripts/test_lint_resource_contracts.py +++ b/scripts/test_lint_resource_contracts.py @@ -9,7 +9,7 @@ class LintResourceContractsTest(unittest.TestCase): def test_location_reader_uses_backported_api(self): - source = (MAIN / "java/com/flowpilot/app/actions/LocationExecutor.kt").read_text() + source = (MAIN / "java/com/flowpilot/app/actions/LocationExecutor.kt").read_text(encoding="utf-8") self.assertIn("LocationManagerCompat.isLocationEnabled(it)", source) def test_permission_revocation_is_explicitly_handled(self): @@ -19,7 +19,7 @@ def test_permission_revocation_is_explicitly_handled(self): ("ui/components/WifiPicker.kt", "wm.scanResults"), ]: with self.subTest(source=relative): - source = (MAIN / "java/com/flowpilot/app" / relative).read_text() + source = (MAIN / "java/com/flowpilot/app" / relative).read_text(encoding="utf-8") next_catch = source[source.index(call):].split("catch (", 1)[1].split(")", 1)[0] self.assertIn("SecurityException", next_catch) @@ -42,7 +42,7 @@ def test_telephony_is_optional(self): def test_conflict_warning_contract(self): java = MAIN / "java/com/flowpilot/app" - analyzer = (java / "analysis/AutomationConflictAnalyzer.kt").read_text() + analyzer = (java / "analysis/AutomationConflictAnalyzer.kt").read_text(encoding="utf-8") expected_actions = { "WIFI_ON", "WIFI_OFF", "BLUETOOTH_ON", "BLUETOOTH_OFF", "MOBILE_DATA_ON", "MOBILE_DATA_OFF", "AIRPLANE_MODE_ON", "AIRPLANE_MODE_OFF", @@ -57,7 +57,7 @@ def test_conflict_warning_contract(self): self.assertNotIn("phoneNumber", analyzer) self.assertNotIn("webhook", analyzer.lower()) for relative in ["ui/screens/CreateScreen.kt", "ui/screens/DetailScreen.kt", "ui/screens/HomeScreen.kt"]: - self.assertIn("AutomationConflictAnalyzer.analyze", (java / relative).read_text()) + self.assertIn("AutomationConflictAnalyzer.analyze", (java / relative).read_text(encoding="utf-8")) if __name__ == "__main__": diff --git a/scripts/test_locale_contracts.py b/scripts/test_locale_contracts.py new file mode 100644 index 0000000..30fb33e --- /dev/null +++ b/scripts/test_locale_contracts.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Run with python scripts/test_locale_contracts.py; no Android SDK needed.""" +from pathlib import Path +import unittest +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +MAIN = ROOT / "app/src/main" + + +class LocaleContractsTest(unittest.TestCase): + + def test_localization_utility_contracts(self): + localization = (MAIN / "java/com/flowpilot/app/ui/util/Localization.kt").read_text(encoding="utf-8") + self.assertIn("fun resolveLocaleLanguage(", localization) + self.assertIn("fun targetLocaleForLanguage(", localization) + self.assertIn("fun applyAppLocale(", localization) + self.assertIn("fun android.content.Context.localizedForAppLanguage(", localization) + self.assertIn("fun android.content.Context.selectedLocaleContext(", localization) + + def test_flowpilot_app_startup_locale_contracts(self): + app = (MAIN / "java/com/flowpilot/app/FlowPilotApp.kt").read_text(encoding="utf-8") + self.assertIn("AutomationRepository.getPersistedLanguage(this)", app) + self.assertIn("applyAppLocale(this, initialLanguage)", app) + self.assertIn("syncPersistedLanguage()", app) + + def test_automation_service_locale_contracts(self): + service = (MAIN / "java/com/flowpilot/app/engine/AutomationService.kt").read_text(encoding="utf-8") + self.assertIn("selectedLocaleContext()", service) + self.assertIn("refreshNotificationLocale(", service) + self.assertIn("notif_channel_engine", service) + self.assertIn("notif_engine_title", service) + self.assertIn("notif_channel_engine_failure", service) + self.assertIn("notif_engine_failure_title", service) + self.assertIn("appLanguage", service) + + def test_automation_repository_locale_contracts(self): + repo = (MAIN / "java/com/flowpilot/app/data/AutomationRepository.kt").read_text(encoding="utf-8") + self.assertIn("fun getPersistedLanguage(", repo) + self.assertIn("fun persistLanguage(", repo) + self.assertIn("suspend fun syncPersistedLanguage(", repo) + self.assertIn("applyAppLocale(context, language)", repo) + self.assertIn("AutomationService.refreshNotificationLocale(context)", repo) + + def test_notification_and_channel_strings_parity(self): + keys = [ + "notif_channel_engine", + "notif_channel_engine_desc", + "notif_engine_title", + "notif_engine_text", + "notif_channel_engine_failure", + "notif_channel_engine_failure_desc", + "notif_engine_failure_title", + "notif_engine_failure_text", + ] + for locale in ("values", "values-tr"): + root = ET.parse(MAIN / f"res/{locale}/strings.xml").getroot() + strings = {node.attrib["name"]: node.text for node in root.findall("string")} + for key in keys: + self.assertIn(key, strings) + self.assertTrue(bool(strings[key] and strings[key].strip())) + + +if __name__ == "__main__": + unittest.main() From 4336fa7470c18d30410e8f9900546127db508b07 Mon Sep 17 00:00:00 2001 From: Emirhan Date: Mon, 14 Sep 2026 23:48:59 +0300 Subject: [PATCH 2/6] fix: sanitize execution history errors and redact sensitive tokens --- .../flowpilot/app/actions/ActionDispatcher.kt | 8 +- .../app/actions/NotificationExecutor.kt | 4 +- .../flowpilot/app/actions/PhoneExecutor.kt | 2 +- .../flowpilot/app/actions/SoundExecutor.kt | 4 +- .../com/flowpilot/app/actions/TtsExecutor.kt | 6 +- .../flowpilot/app/actions/WebhookExecutor.kt | 39 +++++++- .../app/data/AutomationRepository.kt | 46 ++++++++- .../app/data/model/ExecutionHistory.kt | 43 +++++++-- .../flowpilot/app/engine/AutomationEngine.kt | 21 ++-- .../app/actions/SoundExecutorTest.kt | 14 +++ .../data/AutomationRepositoryHistoryTest.kt | 95 +++++++++++++++++++ 11 files changed, 253 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/flowpilot/app/actions/ActionDispatcher.kt b/app/src/main/java/com/flowpilot/app/actions/ActionDispatcher.kt index 622d223..6b2eb64 100644 --- a/app/src/main/java/com/flowpilot/app/actions/ActionDispatcher.kt +++ b/app/src/main/java/com/flowpilot/app/actions/ActionDispatcher.kt @@ -44,7 +44,13 @@ class ActionDispatcher private constructor( fun execute(action: ActionType, parameters: ActionParameters = ActionParameters()): ActionResult { val executor = map[action] ?: return ActionResult(false, "No executor for ${action.label}") - return executor.execute(action, parameters) + return try { + executor.execute(action, parameters) + } catch (ce: java.util.concurrent.CancellationException) { + throw ce + } catch (_: Throwable) { + ActionResult(false, "Execution failed") + } } companion object { diff --git a/app/src/main/java/com/flowpilot/app/actions/NotificationExecutor.kt b/app/src/main/java/com/flowpilot/app/actions/NotificationExecutor.kt index 32b3310..6105a0b 100644 --- a/app/src/main/java/com/flowpilot/app/actions/NotificationExecutor.kt +++ b/app/src/main/java/com/flowpilot/app/actions/NotificationExecutor.kt @@ -44,8 +44,8 @@ class NotificationExecutor( .build() poster(nextId.incrementAndGet(), notification) ActionResult(true, "Notification posted", ActionResultCode.NOTIFICATION_POSTED) - } catch (t: Throwable) { - ActionResult(false, t.message ?: t.javaClass.simpleName) + } catch (_: Throwable) { + ActionResult(false, "Failed to post notification") } } diff --git a/app/src/main/java/com/flowpilot/app/actions/PhoneExecutor.kt b/app/src/main/java/com/flowpilot/app/actions/PhoneExecutor.kt index 05d7ba2..b50acb5 100644 --- a/app/src/main/java/com/flowpilot/app/actions/PhoneExecutor.kt +++ b/app/src/main/java/com/flowpilot/app/actions/PhoneExecutor.kt @@ -87,7 +87,7 @@ class PhoneExecutor( val hasCallPermission = context.checkSelfPermission(Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED if (!hasCallPermission) { Log.w(TAG, "Direct call blocked: CALL_PHONE permission not granted") - return ActionResult(false, "Phone call permission required") + return ActionResult(false, "Phone call permission required", ActionResultCode.PERMISSION_REQUIRED) } val intent = Intent(Intent.ACTION_CALL, Uri.parse("tel:$normalized")).apply { diff --git a/app/src/main/java/com/flowpilot/app/actions/SoundExecutor.kt b/app/src/main/java/com/flowpilot/app/actions/SoundExecutor.kt index e60d4ac..df9eb51 100644 --- a/app/src/main/java/com/flowpilot/app/actions/SoundExecutor.kt +++ b/app/src/main/java/com/flowpilot/app/actions/SoundExecutor.kt @@ -52,8 +52,8 @@ class SoundExecutor( return try { val durationMs = parameters.soundDurationMs.coerceIn(1_000, 60_000) if ((playUri ?: ::play)(uri, durationMs)) ActionResult(true, "Sound played") else ActionResult(false, "Sound could not be played") - } catch (t: Throwable) { - ActionResult(false, t.message ?: t.javaClass.simpleName) + } catch (_: Throwable) { + ActionResult(false, "Sound could not be played") } } diff --git a/app/src/main/java/com/flowpilot/app/actions/TtsExecutor.kt b/app/src/main/java/com/flowpilot/app/actions/TtsExecutor.kt index a84b90f..07969ec 100644 --- a/app/src/main/java/com/flowpilot/app/actions/TtsExecutor.kt +++ b/app/src/main/java/com/flowpilot/app/actions/TtsExecutor.kt @@ -49,7 +49,7 @@ class TtsExecutor( return ActionResult(false, "TTS audio cache missing or not generated") } val file = ttsManager.getCacheFile(fileName) - ?: return ActionResult(false, "TTS cache filename is invalid or unsafe: $fileName") + ?: return ActionResult(false, "TTS cache filename is invalid or unsafe") if (!file.exists() || file.length() == 0L) { return ActionResult(false, "TTS cached audio file is missing or empty") } @@ -60,8 +60,8 @@ class TtsExecutor( } else { ActionResult(false, "TTS audio playback failed") } - } catch (t: Throwable) { - ActionResult(false, t.message ?: t.javaClass.simpleName) + } catch (_: Throwable) { + ActionResult(false, "TTS audio playback failed") } } diff --git a/app/src/main/java/com/flowpilot/app/actions/WebhookExecutor.kt b/app/src/main/java/com/flowpilot/app/actions/WebhookExecutor.kt index 90fa532..dfc83c2 100644 --- a/app/src/main/java/com/flowpilot/app/actions/WebhookExecutor.kt +++ b/app/src/main/java/com/flowpilot/app/actions/WebhookExecutor.kt @@ -165,8 +165,8 @@ class WebhookExecutor internal constructor( val timeoutMs = parameters.webhookTimeoutSeconds.coerceIn(MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS) * 1000 val headers = try { renderHeaders(parameters.webhookHeaders, parameters.webhookTemplateContext) - } catch (e: IllegalArgumentException) { - return ActionResult(false, e.message ?: "Invalid rendered webhook headers") + } catch (_: IllegalArgumentException) { + return ActionResult(false, "Invalid rendered webhook headers") } val body = WebhookTemplateRenderer.render(parameters.webhookBody, parameters.webhookTemplateContext) @@ -446,13 +446,42 @@ class WebhookExecutor internal constructor( } fun redactSensitiveText(text: String): String { - // Redact URIs with queries or credentials, Bearer tokens, passwords, keys in arbitrary error messages or URLs + if (text.isBlank()) return text var redacted = text + + // Credentials in URLs: http(s)://user:pass@host redacted = redacted.replace(Regex("(?i)(https?://)([^\\s:@]+:[^\\s:@]+@)", RegexOption.IGNORE_CASE), "$1[REDACTED]@") + + // Bearer tokens redacted = redacted.replace(Regex("(?i)(bearer\\s+)[A-Za-z0-9_\\-\\.~+/]+=*", RegexOption.IGNORE_CASE), "$1[REDACTED]") - redacted = redacted.replace(Regex("(?i)(key|secret|token|password|auth|api_key|apikey|access_token)=([^&\\s]+)", RegexOption.IGNORE_CASE), "$1=[REDACTED]") + + // Basic auth redacted = redacted.replace(Regex("(?i)(Basic\\s+)[A-Za-z0-9+/=]+", RegexOption.IGNORE_CASE), "$1[REDACTED]") - // Also redact query strings in any URL embedded in text if it has parameters + + // Specific synthetic secret marker and token patterns + redacted = redacted.replace(Regex("(?i)\\bSYNTHETIC_SECRET_DO_NOT_PERSIST\\b"), "[REDACTED]") + redacted = redacted.replace(Regex("(?i)\\b(?:sec|secret|token|apikey|api_key)_[a-zA-Z0-9_]{4,}\\b"), "[REDACTED]") + + // Key/secret/token/password/credential assignments + redacted = redacted.replace(Regex("(?i)(key|secret|token|password|auth|api_key|apikey|access_token)=([^&\\s]+)", RegexOption.IGNORE_CASE), "$1=[REDACTED]") + redacted = redacted.replace(Regex("(?i)\\b(api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|credential|token)[:=\\s]+([A-Za-z0-9_\\-.]{4,})"), "$1: [REDACTED]") + + // Provider URIs (content://, file://, android.resource://) + redacted = redacted.replace(Regex("(?i)\\b(?:content|file|android\\.resource)://[^\\s\"'<>)]+"), "[REDACTED]") + + // Local file paths (/data/..., /storage/..., /sdcard/..., Windows drive paths) + redacted = redacted.replace(Regex("(?i)(?:/(?:data/(?:user|data|app)|storage/emulated|sdcard|system|proc|sys|etc|usr|var|tmp|home|root))/[^\\s\"'<>)]+"), "[REDACTED]") + redacted = redacted.replace(Regex("""(?i)\b[a-zA-Z]:\\[^\s"'<>)]+"""), "[REDACTED]") + + // Stack trace elements: \s+at package.Class.method(...) + redacted = redacted.replace(Regex("""(?i)\s+at\s+[\w$.]+(?:\([\w$.]+:\d+\)|\(Native Method\)|\(Unknown Source\))"""), "") + + // Raw Throwable / Exception class names with optional message + redacted = redacted.replace(Regex("""\b(?:[a-zA-Z_]\w*\.)+[a-zA-Z_]\w*(?:Exception|Error|Throwable)(?::\s*[^\r\n]*)?"""), "[REDACTED]") + redacted = redacted.replace(Regex("""\b[A-Z][a-zA-Z0-9_]*(?:Exception|Throwable)(?::\s*[^\r\n]*)?"""), "[REDACTED]") + redacted = redacted.replace(Regex("""\b[A-Z][a-zA-Z0-9_]+Error(?::\s*[^\r\n]*)?"""), "[REDACTED]") + + // URL query string redaction (redact param values in any http/https URL) redacted = redacted.replace(Regex("(?i)(https?://[^\\s?#]+)\\?([^\\s#]+)")) { matchResult -> val base = matchResult.groupValues[1] val query = matchResult.groupValues[2] diff --git a/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt b/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt index f3a3dbe..1e120de 100644 --- a/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt +++ b/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt @@ -116,7 +116,21 @@ class AutomationRepository(private val context: Context) { val executionHistory: Flow> = context.dataStore.data.map { prefs -> val history = prefs[historyKey]?.let { safeDecodeHistory(it) }.orEmpty() - if (history.any { it.ruleName != it.normalizedRuleName }) { + val migratedHistory = history.map { entry -> + entry.copy( + ruleName = entry.normalizedRuleName, + actions = entry.actions.map { action -> + ActionExecutionRecord.create( + actionType = action.actionType, + success = action.success, + message = action.message, + resultCode = action.resultCode, + resultArgs = action.resultArgs, + ) + }, + ) + } + if (migratedHistory != history) { val migrated = context.dataStore.edit { migrateHistory(it) } migrated[historyKey]?.let { safeDecodeHistory(it) }.orEmpty() } else { @@ -128,7 +142,20 @@ class AutomationRepository(private val context: Context) { context.dataStore.edit { prefs -> migrateHistory(prefs) val current = prefs[historyKey]?.let { safeDecodeHistory(it) } ?: emptyList() - val updated = (listOf(entry.copy(ruleName = entry.normalizedRuleName)) + current).take(MAX_HISTORY_ENTRIES) + val sanitizedActions = entry.actions.map { action -> + ActionExecutionRecord.create( + actionType = action.actionType, + success = action.success, + message = action.message, + resultCode = action.resultCode, + resultArgs = action.resultArgs, + ) + } + val sanitizedEntry = entry.copy( + ruleName = entry.normalizedRuleName, + actions = sanitizedActions, + ) + val updated = (listOf(sanitizedEntry) + current).take(MAX_HISTORY_ENTRIES) prefs[historyKey] = json.encodeToString(historySerializer, updated) } } @@ -782,7 +809,20 @@ class AutomationRepository(private val context: Context) { private fun migrateHistory(prefs: MutablePreferences) { val raw = prefs[historyKey] ?: return val history = safeDecodeHistory(raw) - val migrated = history.map { it.copy(ruleName = it.normalizedRuleName) } + val migrated = history.map { entry -> + entry.copy( + ruleName = entry.normalizedRuleName, + actions = entry.actions.map { action -> + ActionExecutionRecord.create( + actionType = action.actionType, + success = action.success, + message = action.message, + resultCode = action.resultCode, + resultArgs = action.resultArgs, + ) + }, + ) + } if (migrated != history) { prefs[historyKey] = json.encodeToString(historySerializer, migrated) } diff --git a/app/src/main/java/com/flowpilot/app/data/model/ExecutionHistory.kt b/app/src/main/java/com/flowpilot/app/data/model/ExecutionHistory.kt index 0367e8a..9ae65c4 100644 --- a/app/src/main/java/com/flowpilot/app/data/model/ExecutionHistory.kt +++ b/app/src/main/java/com/flowpilot/app/data/model/ExecutionHistory.kt @@ -46,9 +46,9 @@ data class ActionExecutionRecord( resultCode: ActionResultCode? = null, resultArgs: List = emptyList(), ): ActionExecutionRecord { - val safeMessage = WebhookExecutor.redactSensitiveText(message) - val safeArgs = resultArgs.map(WebhookExecutor::redactSensitiveText).let { args -> - if (resultCode == ActionResultCode.SMS_SENT) args.map(PhoneNumberUtils::mask) else args + val safeMessage = sanitizeMessage(message, success, actionType) + val safeArgs = resultArgs.map(WebhookExecutor::redactSensitiveText).map { arg -> + maskPhoneNumber(arg) } return ActionExecutionRecord( actionType = actionType, @@ -60,6 +60,26 @@ data class ActionExecutionRecord( ) } + private fun sanitizeMessage(message: String, success: Boolean, actionType: ActionType): String { + val redacted = WebhookExecutor.redactSensitiveText(message) + val masked = maskPhoneNumber(redacted) + return when { + masked.isBlank() || masked == "[REDACTED]" -> if (!success) "Execution failed" else actionType.label + else -> masked + } + } + + private fun maskPhoneNumber(text: String): String { + var result = text + result = result.replace(Regex("""(? + PhoneNumberUtils.mask(match.value) + } + result = result.replace(Regex("""(? + PhoneNumberUtils.mask(match.value) + } + return result + } + internal fun successCodeFor(actionType: ActionType, success: Boolean): ActionResultCode? { if (!success) return null return when (actionType) { @@ -130,14 +150,25 @@ data class ExecutionHistoryEntry( val successCount = actions.count { it.success } val failureCount = actions.count { !it.success } val status = ExecutionStatus.fromCounts(successCount, failureCount) + val safeRuleName = WebhookExecutor.redactSensitiveText(ruleName) + val safeTrigger = WebhookExecutor.redactSensitiveText(trigger) + val sanitizedActions = actions.map { action -> + ActionExecutionRecord.create( + actionType = action.actionType, + success = action.success, + message = action.message, + resultCode = action.resultCode, + resultArgs = action.resultArgs, + ) + } return ExecutionHistoryEntry( id = id, ruleId = ruleId, - ruleName = ruleName, - trigger = trigger, + ruleName = safeRuleName, + trigger = safeTrigger, timestamp = timestamp, status = status, - actions = actions, + actions = sanitizedActions, ) } } diff --git a/app/src/main/java/com/flowpilot/app/engine/AutomationEngine.kt b/app/src/main/java/com/flowpilot/app/engine/AutomationEngine.kt index 4b295a6..ded2424 100644 --- a/app/src/main/java/com/flowpilot/app/engine/AutomationEngine.kt +++ b/app/src/main/java/com/flowpilot/app/engine/AutomationEngine.kt @@ -566,12 +566,21 @@ class AutomationEngine( } currentCoroutineContext().ensureActive() - val result = repository.dispatchIfAuthorized(reservation) { - eventAuthorization?.let { authorization -> - AutomationService.executeIfEventAuthorized(authorization) { - dispatcher.execute(action, actionParameters(rule, templateContext)) - } - } ?: dispatcher.execute(action, actionParameters(rule, templateContext)) + val result = try { + repository.dispatchIfAuthorized(reservation) { + eventAuthorization?.let { authorization -> + AutomationService.executeIfEventAuthorized(authorization) { + dispatcher.execute(action, actionParameters(rule, templateContext)) + } + } ?: dispatcher.execute(action, actionParameters(rule, templateContext)) + } + } catch (ce: CancellationException) { + throw ce + } catch (_: Throwable) { + com.flowpilot.app.actions.ActionResult( + success = false, + message = "Execution failed", + ) } if (result == null) { actionRecords.add( diff --git a/app/src/test/java/com/flowpilot/app/actions/SoundExecutorTest.kt b/app/src/test/java/com/flowpilot/app/actions/SoundExecutorTest.kt index 18cfc59..f3ca0c6 100644 --- a/app/src/test/java/com/flowpilot/app/actions/SoundExecutorTest.kt +++ b/app/src/test/java/com/flowpilot/app/actions/SoundExecutorTest.kt @@ -36,4 +36,18 @@ class SoundExecutorTest { @Test fun stopPreview_isSafe_whenNothingIsPlaying() { SoundExecutor(RuntimeEnvironment.getApplication(), playUri = { _, _ -> true }).stopPreview() } + + @Test fun execute_whenPlayThrowsSyntheticSecretAndPrivateUri_returnsSafeGenericFailure() { + val secret = "SYNTHETIC_SECRET_DO_NOT_PERSIST" + val privateUri = "content://com.flowpilot.test.provider/synthetic/private/uri" + val result = SoundExecutor( + RuntimeEnvironment.getApplication(), + playUri = { _, _ -> throw RuntimeException("Failed to access $privateUri: $secret") }, + ).execute(ActionType.PLAY_SOUND, ActionParameters(soundPreset = SoundPreset.CUSTOM, soundUri = privateUri)) + + assertThat(result.success).isFalse() + assertThat(result.message).doesNotContain(secret) + assertThat(result.message).doesNotContain(privateUri) + assertThat(result.message).isEqualTo("Sound could not be played") + } } diff --git a/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryHistoryTest.kt b/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryHistoryTest.kt index 60b5947..cbe9d86 100644 --- a/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryHistoryTest.kt +++ b/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryHistoryTest.kt @@ -387,4 +387,99 @@ class AutomationRepositoryHistoryTest { assertThat(technicalError.resolvedResultCode()).isNull() assertThat(technicalError.message).isEqualTo("HTTP 503 from upstream") } + + @Test + fun appendHistory_withInjectedSyntheticSecretAndPrivateUri_isAbsentFromSerializedAndPersistedHistory() = runTest { + val syntheticSecret = "SYNTHETIC_SECRET_DO_NOT_PERSIST" + val syntheticUri = "content://com.flowpilot.test.provider/synthetic/private/uri" + val syntheticLocalPath = "/data/user/0/com.flowpilot.app/files/secret.key" + val rawErrorMessage = "java.lang.IllegalStateException: Failed accessing $syntheticUri with $syntheticSecret at $syntheticLocalPath" + + val entry = ExecutionHistoryEntry.create( + ruleId = "leak-test-rule", + ruleName = "Rule $syntheticSecret", + trigger = "MANUAL", + actions = listOf( + ActionExecutionRecord.create( + actionType = ActionType.PLAY_SOUND, + result = ActionResult( + success = false, + message = rawErrorMessage, + resultCode = null, + resultArgs = listOf(syntheticUri, syntheticSecret, syntheticLocalPath), + ), + ), + ), + ) + + repository.appendHistory(entry) + + val rawSerialized = repository.rawDataStore.data.first()[historyKey]!! + assertThat(rawSerialized).doesNotContain(syntheticSecret) + assertThat(rawSerialized).doesNotContain(syntheticUri) + assertThat(rawSerialized).doesNotContain("/data/user/0") + assertThat(rawSerialized).doesNotContain("IllegalStateException") + + val persisted = repository.executionHistory.first().first { it.ruleId == "leak-test-rule" } + val action = persisted.actions.single() + + assertThat(action.message).doesNotContain(syntheticSecret) + assertThat(action.message).doesNotContain(syntheticUri) + assertThat(action.message).doesNotContain("/data/user/0") + assertThat(action.message).doesNotContain("IllegalStateException") + for (arg in action.resultArgs) { + assertThat(arg).doesNotContain(syntheticSecret) + assertThat(arg).doesNotContain(syntheticUri) + assertThat(arg).doesNotContain("/data/user/0") + } + } + + @Test + fun legacyPersistedHistory_withInjectedSyntheticSecretAndPrivateUri_isSanitizedOnMigration() = runTest { + val syntheticSecret = "SYNTHETIC_SECRET_DO_NOT_PERSIST" + val syntheticUri = "content://com.flowpilot.test.provider/synthetic/private/uri" + val rawThrowableMessage = "java.io.FileNotFoundException: /data/user/0/com.flowpilot/databases/test.db: $syntheticSecret ($syntheticUri)" + + val legacyEntry = ExecutionHistoryEntry( + id = "legacy-leak", + ruleId = "legacy-rule", + ruleName = "Rule with $syntheticSecret", + trigger = "MANUAL", + timestamp = 100L, + status = ExecutionStatus.FAILURE, + actions = listOf( + ActionExecutionRecord( + actionType = ActionType.PLAY_SOUND, + actionLabel = ActionType.PLAY_SOUND.label, + success = false, + message = rawThrowableMessage, + resultCode = null, + resultArgs = listOf(syntheticUri, syntheticSecret), + ), + ), + ) + + repository.rawDataStore.edit { prefs -> + prefs[historyKey] = json.encodeToString(historySerializer, listOf(legacyEntry)) + } + + val loadedHistory = repository.executionHistory.first() + val loadedEntry = loadedHistory.single() + val loadedAction = loadedEntry.actions.single() + + assertThat(loadedAction.message).doesNotContain(syntheticSecret) + assertThat(loadedAction.message).doesNotContain(syntheticUri) + assertThat(loadedAction.message).doesNotContain("/data/user/0") + assertThat(loadedAction.message).doesNotContain("FileNotFoundException") + for (arg in loadedAction.resultArgs) { + assertThat(arg).doesNotContain(syntheticSecret) + assertThat(arg).doesNotContain(syntheticUri) + } + + val migratedRaw = repository.rawDataStore.data.first()[historyKey]!! + assertThat(migratedRaw).doesNotContain(syntheticSecret) + assertThat(migratedRaw).doesNotContain(syntheticUri) + assertThat(migratedRaw).doesNotContain("/data/user/0") + assertThat(migratedRaw).doesNotContain("FileNotFoundException") + } } From 738a0cfedba39ab61857b7e9cc3f7cca650e08c3 Mon Sep 17 00:00:00 2001 From: Emirhan Date: Tue, 15 Sep 2026 00:06:44 +0300 Subject: [PATCH 3/6] fix: sanitize history fields, unstick system locale, and avoid startup disk lock (#19, #23) --- .../app/data/AutomationRepository.kt | 61 +++---------------- .../app/data/model/ExecutionHistory.kt | 39 +++++++----- .../flowpilot/app/engine/AutomationService.kt | 1 + .../com/flowpilot/app/ui/util/Localization.kt | 8 ++- .../data/AutomationRepositoryHistoryTest.kt | 25 +++++++- .../app/ui/util/LocaleSelectionTest.kt | 27 ++++++++ 6 files changed, 89 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt b/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt index 7410d73..22d3bb9 100644 --- a/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt +++ b/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt @@ -76,6 +76,8 @@ class AutomationRepository(private val context: Context) { suspend fun syncPersistedLanguage(): String { val lang = appLanguage.first() persistLanguage(context, lang) + applyAppLocale(context, lang) + AutomationService.refreshNotificationLocale(context) return lang } @@ -128,23 +130,10 @@ class AutomationRepository(private val context: Context) { val executionHistory: Flow> = context.dataStore.data.map { prefs -> val history = prefs[historyKey]?.let { safeDecodeHistory(it) }.orEmpty() - val migratedHistory = history.map { entry -> - entry.copy( - ruleName = entry.normalizedRuleName, - actions = entry.actions.map { action -> - ActionExecutionRecord.create( - actionType = action.actionType, - success = action.success, - message = action.message, - resultCode = action.resultCode, - resultArgs = action.resultArgs, - ) - }, - ) - } + val migratedHistory = history.map { it.sanitized() } if (migratedHistory != history) { val migrated = context.dataStore.edit { migrateHistory(it) } - migrated[historyKey]?.let { safeDecodeHistory(it) }.orEmpty() + migrated[historyKey]?.let { safeDecodeHistory(it) }?.map { it.sanitized() } ?: migratedHistory } else { history } @@ -154,19 +143,7 @@ class AutomationRepository(private val context: Context) { context.dataStore.edit { prefs -> migrateHistory(prefs) val current = prefs[historyKey]?.let { safeDecodeHistory(it) } ?: emptyList() - val sanitizedActions = entry.actions.map { action -> - ActionExecutionRecord.create( - actionType = action.actionType, - success = action.success, - message = action.message, - resultCode = action.resultCode, - resultArgs = action.resultArgs, - ) - } - val sanitizedEntry = entry.copy( - ruleName = entry.normalizedRuleName, - actions = sanitizedActions, - ) + val sanitizedEntry = entry.sanitized() val updated = (listOf(sanitizedEntry) + current).take(MAX_HISTORY_ENTRIES) prefs[historyKey] = json.encodeToString(historySerializer, updated) } @@ -821,20 +798,7 @@ class AutomationRepository(private val context: Context) { private fun migrateHistory(prefs: MutablePreferences) { val raw = prefs[historyKey] ?: return val history = safeDecodeHistory(raw) - val migrated = history.map { entry -> - entry.copy( - ruleName = entry.normalizedRuleName, - actions = entry.actions.map { action -> - ActionExecutionRecord.create( - actionType = action.actionType, - success = action.success, - message = action.message, - resultCode = action.resultCode, - resultArgs = action.resultArgs, - ) - }, - ) - } + val migrated = history.map { it.sanitized() } if (migrated != history) { prefs[historyKey] = json.encodeToString(historySerializer, migrated) } @@ -892,18 +856,7 @@ class AutomationRepository(private val context: Context) { fun getPersistedLanguage(context: Context): String { val prefs = context.getSharedPreferences(PREFS_LOCALE, Context.MODE_PRIVATE) - val cached = prefs.getString(KEY_APP_LANGUAGE, null) - if (cached != null) return cached - - val fromDataStore = runCatching { - kotlinx.coroutines.runBlocking(Dispatchers.IO) { - AutomationRepository(context).appLanguage.first() - } - }.getOrNull() - - val language = fromDataStore ?: "system" - persistLanguage(context, language) - return language + return prefs.getString(KEY_APP_LANGUAGE, null) ?: "system" } fun persistLanguage(context: Context, language: String) { diff --git a/app/src/main/java/com/flowpilot/app/data/model/ExecutionHistory.kt b/app/src/main/java/com/flowpilot/app/data/model/ExecutionHistory.kt index 9ae65c4..cb9303a 100644 --- a/app/src/main/java/com/flowpilot/app/data/model/ExecutionHistory.kt +++ b/app/src/main/java/com/flowpilot/app/data/model/ExecutionHistory.kt @@ -138,6 +138,25 @@ data class ExecutionHistoryEntry( ruleName } + fun sanitized(): ExecutionHistoryEntry { + val safeRuleName = WebhookExecutor.redactSensitiveText(normalizedRuleName) + val safeTrigger = WebhookExecutor.redactSensitiveText(trigger) + val sanitizedActions = actions.map { action -> + ActionExecutionRecord.create( + actionType = action.actionType, + success = action.success, + message = action.message, + resultCode = action.resultCode, + resultArgs = action.resultArgs, + ) + } + return copy( + ruleName = safeRuleName, + trigger = safeTrigger, + actions = sanitizedActions, + ) + } + companion object { fun create( id: String = java.util.UUID.randomUUID().toString(), @@ -150,26 +169,16 @@ data class ExecutionHistoryEntry( val successCount = actions.count { it.success } val failureCount = actions.count { !it.success } val status = ExecutionStatus.fromCounts(successCount, failureCount) - val safeRuleName = WebhookExecutor.redactSensitiveText(ruleName) - val safeTrigger = WebhookExecutor.redactSensitiveText(trigger) - val sanitizedActions = actions.map { action -> - ActionExecutionRecord.create( - actionType = action.actionType, - success = action.success, - message = action.message, - resultCode = action.resultCode, - resultArgs = action.resultArgs, - ) - } - return ExecutionHistoryEntry( + val entry = ExecutionHistoryEntry( id = id, ruleId = ruleId, - ruleName = safeRuleName, - trigger = safeTrigger, + ruleName = ruleName, + trigger = trigger, timestamp = timestamp, status = status, - actions = sanitizedActions, + actions = actions, ) + return entry.sanitized() } } } diff --git a/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt b/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt index 42acfff..389ee55 100644 --- a/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt +++ b/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt @@ -195,6 +195,7 @@ class AutomationService : Service() { companion object { private val controlMutex = Mutex() private val eventExecutionAuthorization = EventExecutionAuthorization() + @Volatile private var activeService: AutomationService? = null private val mutableRunning = MutableStateFlow(false) val running = mutableRunning.asStateFlow() diff --git a/app/src/main/java/com/flowpilot/app/ui/util/Localization.kt b/app/src/main/java/com/flowpilot/app/ui/util/Localization.kt index 2cc91fe..ba65f93 100644 --- a/app/src/main/java/com/flowpilot/app/ui/util/Localization.kt +++ b/app/src/main/java/com/flowpilot/app/ui/util/Localization.kt @@ -378,9 +378,14 @@ fun resolveLocaleLanguage(language: String?): String = when (language?.lowercase else -> "system" } +fun systemResourcesLocale(): java.util.Locale = runCatching { + val locales = android.content.res.Resources.getSystem().configuration.locales + if (!locales.isEmpty) locales.get(0) else null +}.getOrNull() ?: java.util.Locale.getDefault() + fun targetLocaleForLanguage( language: String, - defaultLocale: java.util.Locale = java.util.Locale.getDefault(), + defaultLocale: java.util.Locale = systemResourcesLocale(), ): java.util.Locale = when (resolveLocaleLanguage(language)) { "tr" -> java.util.Locale.forLanguageTag("tr") "en" -> java.util.Locale.forLanguageTag("en") @@ -405,6 +410,7 @@ fun applyAppLocale(context: android.content.Context, language: String) { when (targetTag) { "tr" -> java.util.Locale.setDefault(java.util.Locale.forLanguageTag("tr")) "en" -> java.util.Locale.setDefault(java.util.Locale.forLanguageTag("en")) + else -> java.util.Locale.setDefault(systemResourcesLocale()) } } diff --git a/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryHistoryTest.kt b/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryHistoryTest.kt index cbe9d86..00700ea 100644 --- a/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryHistoryTest.kt +++ b/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryHistoryTest.kt @@ -443,8 +443,8 @@ class AutomationRepositoryHistoryTest { val legacyEntry = ExecutionHistoryEntry( id = "legacy-leak", ruleId = "legacy-rule", - ruleName = "Rule with $syntheticSecret", - trigger = "MANUAL", + ruleName = "Rule with $syntheticSecret ($syntheticUri)", + trigger = "TRIGGER_$syntheticSecret", timestamp = 100L, status = ExecutionStatus.FAILURE, actions = listOf( @@ -467,6 +467,11 @@ class AutomationRepositoryHistoryTest { val loadedEntry = loadedHistory.single() val loadedAction = loadedEntry.actions.single() + assertThat(loadedEntry.ruleName).doesNotContain(syntheticSecret) + assertThat(loadedEntry.ruleName).doesNotContain(syntheticUri) + assertThat(loadedEntry.trigger).doesNotContain(syntheticSecret) + assertThat(loadedEntry.trigger).doesNotContain(syntheticUri) + assertThat(loadedAction.message).doesNotContain(syntheticSecret) assertThat(loadedAction.message).doesNotContain(syntheticUri) assertThat(loadedAction.message).doesNotContain("/data/user/0") @@ -482,4 +487,20 @@ class AutomationRepositoryHistoryTest { assertThat(migratedRaw).doesNotContain("/data/user/0") assertThat(migratedRaw).doesNotContain("FileNotFoundException") } + + @Test + fun getPersistedLanguage_onCacheMiss_returnsSystem_andSyncPopulatesCache() = runTest { + val prefs = context.getSharedPreferences(AutomationRepository.PREFS_LOCALE, Context.MODE_PRIVATE) + prefs.edit().clear().commit() + + val initial = AutomationRepository.getPersistedLanguage(context) + assertThat(initial).isEqualTo("system") + + repository.rawDataStore.edit { it[stringPreferencesKey("app_language")] = "tr" } + assertThat(prefs.contains(AutomationRepository.KEY_APP_LANGUAGE)).isFalse() + + val synced = repository.syncPersistedLanguage() + assertThat(synced).isEqualTo("tr") + assertThat(prefs.getString(AutomationRepository.KEY_APP_LANGUAGE, null)).isEqualTo("tr") + } } diff --git a/app/src/test/java/com/flowpilot/app/ui/util/LocaleSelectionTest.kt b/app/src/test/java/com/flowpilot/app/ui/util/LocaleSelectionTest.kt index a3ae778..f9c998f 100644 --- a/app/src/test/java/com/flowpilot/app/ui/util/LocaleSelectionTest.kt +++ b/app/src/test/java/com/flowpilot/app/ui/util/LocaleSelectionTest.kt @@ -36,4 +36,31 @@ class LocaleSelectionTest { val unknownLocale = targetLocaleForLanguage("unknown", fallback) assertEquals(fallback, unknownLocale) } + + @Test + fun `targetLocaleForLanguage with system uses supplied system locale regardless of mutated Locale default`() { + val previousDefault = Locale.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("tr")) + val systemLocale = Locale("de", "DE") + val resolved = targetLocaleForLanguage("system", systemLocale) + assertEquals("de", resolved.language) + assertEquals(systemLocale, resolved) + } finally { + Locale.setDefault(previousDefault) + } + } + + @Test + fun `systemResourcesLocale safely returns non-null fallback in test environment`() { + val locale = systemResourcesLocale() + org.junit.Assert.assertNotNull(locale) + } + + @Test + fun `targetLocaleForLanguage with system defaults to systemResourcesLocale`() { + val expected = systemResourcesLocale() + val actual = targetLocaleForLanguage("system") + assertEquals(expected, actual) + } } From 36e24352ad25603887b0439bcdf7f0d27b59c07e Mon Sep 17 00:00:00 2001 From: Emirhan Date: Tue, 15 Sep 2026 00:15:36 +0300 Subject: [PATCH 4/6] fix: refresh failure channel metadata on active service locale switch (#23) --- .../main/java/com/flowpilot/app/engine/AutomationService.kt | 1 + scripts/test_locale_contracts.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt b/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt index 389ee55..be48252 100644 --- a/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt +++ b/app/src/main/java/com/flowpilot/app/engine/AutomationService.kt @@ -360,6 +360,7 @@ class AutomationService : Service() { val service = activeService if (service != null) { service.createChannel() + ensureFailureChannel(context) try { val nm = service.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager nm?.notify(NOTIF_ID, service.buildNotification()) diff --git a/scripts/test_locale_contracts.py b/scripts/test_locale_contracts.py index 30fb33e..a0333c7 100644 --- a/scripts/test_locale_contracts.py +++ b/scripts/test_locale_contracts.py @@ -34,6 +34,12 @@ def test_automation_service_locale_contracts(self): self.assertIn("notif_engine_failure_title", service) self.assertIn("appLanguage", service) + refresh_fn = service.split("fun refreshNotificationLocale(", 1)[1].split("private fun start(", 1)[0] + service_branch = refresh_fn.split("if (service != null)", 1)[1].split("} else {", 1)[0] + else_branch = refresh_fn.split("} else {", 1)[1].split("val hasFailure =", 1)[0] + self.assertIn("ensureFailureChannel(context)", service_branch) + self.assertIn("ensureFailureChannel(context)", else_branch) + def test_automation_repository_locale_contracts(self): repo = (MAIN / "java/com/flowpilot/app/data/AutomationRepository.kt").read_text(encoding="utf-8") self.assertIn("fun getPersistedLanguage(", repo) From 3aa95300818c619e2d71a4c595e67e446a94cbcc Mon Sep 17 00:00:00 2001 From: Emirhan Date: Tue, 15 Sep 2026 00:17:02 +0300 Subject: [PATCH 5/6] docs: clarify localized notifications and history redaction --- README.md | 4 ++-- README.tr.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6f39e91..991629e 100644 --- a/README.md +++ b/README.md @@ -200,11 +200,11 @@ Chain multiple actions in any custom sequence with drag-and-drop ordering and in --- ### 4. Smart Productivity & Controls -- **Quick Settings Tile:** Toggle the automation engine or inspect live status directly from Android's notification shade. +- **Quick Settings Tile & Engine Notification:** Toggle the automation engine or inspect live status directly from Android's notification shade. Engine status and startup-failure notifications follow FlowPilot's English, Turkish, or system-language setting even after a background restart. - **Material 3 Home Screen Widget:** Glance-powered widget displaying active rule counts with a one-tap pause/resume button. - **In-App Live Test Run:** Test any rule action directly inside the editor before saving to verify parameters. - **Safe Rule Duplication:** Clone any rule into an immediately editable disabled copy with freshly encrypted webhook credentials. -- **Execution Run History:** Local persistent audit trail of the last 100 executions with masked sensitive details. +- **Execution Run History:** Local persistent audit trail of the last 100 executions with masked sensitive details. Raw provider errors, private URIs, local paths, credentials, and phone numbers are not persisted. - **Conflict Warnings:** Automatic non-blocking analysis warning you when opposite state actions target the same trigger. --- diff --git a/README.tr.md b/README.tr.md index b461643..788cb1a 100644 --- a/README.tr.md +++ b/README.tr.md @@ -200,11 +200,11 @@ Tek bir kuralda birden çok eylemi sürükle-bırak yöntemiyle dilediğiniz sı --- ### 4. Akıllı Verimlilik & Kolaylıklar -- **Hızlı Ayarlar Kutusu (Quick Settings Tile):** Otomasyon motorunu bildirim çubuğundan tek dokunuşla açıp kapatabilme veya canlı durumunu görme. +- **Hızlı Ayarlar Kutusu ve Motor Bildirimi:** Otomasyon motorunu bildirim çubuğundan tek dokunuşla açıp kapatabilme veya canlı durumunu görme. Motor durumu ve başlatma hatası bildirimleri, arka plan yeniden başlatmalarından sonra da FlowPilot'ın İngilizce, Türkçe veya sistem dili ayarını izler. - **Material 3 Ana Ekran Widget'ı:** Aktif kural sayısını gösteren ve tek dokunuşla motoru duraklatıp sürdüren Glance widget'ı. - **Canlı Eylem Testi:** Bir kuralı kaydetmeden önce oluşturduğunuz eylemleri doğrudan cihazınızda test edebilme. - **Güvenli Kural Çoğaltma:** Mevcut bir kuralı tek tıkla çoğaltma; webhook şifreleri hedef kopya için Keystore ile yeniden şifrelenir. -- **Çalışma Geçmişi:** Son 100 kural tetiklenmesini, eylem bazında sonuçları ve maskelenmiş güvenli detaylarıyla yerel günlükte saklama. +- **Çalışma Geçmişi:** Son 100 kural tetiklenmesini, eylem bazında sonuçları ve maskelenmiş güvenli detaylarıyla yerel günlükte saklama. Ham sağlayıcı hataları, özel URI'ler, yerel dosya yolları, kimlik bilgileri ve telefon numaraları kalıcı olarak saklanmaz. - **Çakışma Uyarıları:** Birbirine zıt durum eylemleri içeren kurallarda otomatik, engelleyici olmayan akıllı uyarı sistemi. --- From 7246f7705eb3eb1c6af3aada6e25254f1d868019 Mon Sep 17 00:00:00 2001 From: Emirhan Date: Tue, 15 Sep 2026 02:55:12 +0300 Subject: [PATCH 6/6] fix: redact embedded synthetic history markers --- app/src/main/java/com/flowpilot/app/actions/WebhookExecutor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/flowpilot/app/actions/WebhookExecutor.kt b/app/src/main/java/com/flowpilot/app/actions/WebhookExecutor.kt index dfc83c2..da17ea0 100644 --- a/app/src/main/java/com/flowpilot/app/actions/WebhookExecutor.kt +++ b/app/src/main/java/com/flowpilot/app/actions/WebhookExecutor.kt @@ -459,7 +459,7 @@ class WebhookExecutor internal constructor( redacted = redacted.replace(Regex("(?i)(Basic\\s+)[A-Za-z0-9+/=]+", RegexOption.IGNORE_CASE), "$1[REDACTED]") // Specific synthetic secret marker and token patterns - redacted = redacted.replace(Regex("(?i)\\bSYNTHETIC_SECRET_DO_NOT_PERSIST\\b"), "[REDACTED]") + redacted = redacted.replace(Regex("(?i)SYNTHETIC_SECRET_DO_NOT_PERSIST"), "[REDACTED]") redacted = redacted.replace(Regex("(?i)\\b(?:sec|secret|token|apikey|api_key)_[a-zA-Z0-9_]{4,}\\b"), "[REDACTED]") // Key/secret/token/password/credential assignments