Skip to content
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
4 changes: 2 additions & 2 deletions README.tr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/flowpilot/app/FlowPilotApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/flowpilot/app/actions/SoundExecutor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
6 changes: 3 additions & 3 deletions app/src/main/java/com/flowpilot/app/actions/TtsExecutor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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")
}
}

Expand Down
39 changes: 34 additions & 5 deletions app/src/main/java/com/flowpilot/app/actions/WebhookExecutor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)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
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]
Expand Down
38 changes: 34 additions & 4 deletions app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,10 +73,21 @@ class AutomationRepository(private val context: Context) {
.associateBy { it.automationId }
}

suspend fun syncPersistedLanguage(): String {
val lang = appLanguage.first()
persistLanguage(context, lang)
applyAppLocale(context, lang)
AutomationService.refreshNotificationLocale(context)
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<String> = context.dataStore.data.map { prefs ->
Expand Down Expand Up @@ -116,9 +130,10 @@ class AutomationRepository(private val context: Context) {

val executionHistory: Flow<List<ExecutionHistoryEntry>> = context.dataStore.data.map { prefs ->
val history = prefs[historyKey]?.let { safeDecodeHistory(it) }.orEmpty()
if (history.any { it.ruleName != it.normalizedRuleName }) {
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
}
Expand All @@ -128,7 +143,8 @@ 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 sanitizedEntry = entry.sanitized()
val updated = (listOf(sanitizedEntry) + current).take(MAX_HISTORY_ENTRIES)
prefs[historyKey] = json.encodeToString(historySerializer, updated)
}
}
Expand Down Expand Up @@ -782,7 +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 { it.copy(ruleName = it.normalizedRuleName) }
val migrated = history.map { it.sanitized() }
if (migrated != history) {
prefs[historyKey] = json.encodeToString(historySerializer, migrated)
}
Expand Down Expand Up @@ -835,6 +851,20 @@ 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)
return prefs.getString(KEY_APP_LANGUAGE, null) ?: "system"
}

fun persistLanguage(context: Context, language: String) {
context.getSharedPreferences(PREFS_LOCALE, Context.MODE_PRIVATE)
.edit()
.putString(KEY_APP_LANGUAGE, language)
.apply()
}
}

suspend fun migrateLegacySecretsIfNeeded() {
Expand Down
48 changes: 44 additions & 4 deletions app/src/main/java/com/flowpilot/app/data/model/ExecutionHistory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ data class ActionExecutionRecord(
resultCode: ActionResultCode? = null,
resultArgs: List<String> = 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,
Expand All @@ -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("""(?<!\w)(?:\+\d{1,3}[\s-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}(?!\w)""")) { match ->
PhoneNumberUtils.mask(match.value)
}
result = result.replace(Regex("""(?<!\w)\+\d{7,15}(?!\w)""")) { match ->
PhoneNumberUtils.mask(match.value)
}
return result
}

internal fun successCodeFor(actionType: ActionType, success: Boolean): ActionResultCode? {
if (!success) return null
return when (actionType) {
Expand Down Expand Up @@ -118,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(),
Expand All @@ -130,7 +169,7 @@ data class ExecutionHistoryEntry(
val successCount = actions.count { it.success }
val failureCount = actions.count { !it.success }
val status = ExecutionStatus.fromCounts(successCount, failureCount)
return ExecutionHistoryEntry(
val entry = ExecutionHistoryEntry(
id = id,
ruleId = ruleId,
ruleName = ruleName,
Expand All @@ -139,6 +178,7 @@ data class ExecutionHistoryEntry(
status = status,
actions = actions,
)
return entry.sanitized()
}
}
}
Loading
Loading