diff --git a/README.md b/README.md index 1aacc60..98fb4cb 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ FlowPilot includes pre-built one-tap templates to get started quickly: - **Quick Settings Tile:** Toggle the automation engine or view live status directly from Android notification shade. - **Home Screen Widget (Jetpack Glance):** Modern widget displaying active rule counts with one-tap pause/resume button. - **In-App Manual Test Run:** Test rule actions directly while editing with real parameters without needing to save first. +- **Safe Rule Duplication:** Duplicate any rule from its list menu into an immediately editable, disabled copy. Configuration is preserved, runtime state is reset, and webhook secrets receive fresh Android Keystore ciphertext. - **Execution Run History:** Local persistent audit log of the last 100 executions with per-action outcomes rendered in the selected app language; credentials and phone numbers remain redacted/masked. --- diff --git a/README.tr.md b/README.tr.md index 48bc812..f546c57 100644 --- a/README.tr.md +++ b/README.tr.md @@ -124,6 +124,7 @@ Tek tıkla kullanıma hazır popüler senaryolar: - **Hızlı Ayarlar Kutusu (Quick Settings Tile):** Bildirim panelinden tek tıkla otomasyon motorunu açıp kapatabilme veya durum izleme. - **Ana Ekran Widget'ı (Jetpack Glance):** Aktif kural sayısını gösteren ve tek dokunuşla motoru duraklatıp devam ettiren şık Material 3 widget'ı. - **Canlı Eylem Testi:** Bir kuralı kaydetmeden önce, üzerindeki tüm düzenlemeleri doğrudan cihazda anında test edebilme. +- **Güvenli Kural Çoğaltma:** Liste menüsünden bir kuralı, hemen düzenlenmek üzere devre dışı bir kopyaya çoğaltabilme. Yapılandırma korunur, çalışma durumu sıfırlanır ve webhook sırları yeni Android Keystore şifreli metinleriyle saklanır. - **Çalışma Geçmişi:** Son 100 kural tetiklenmesini, seçili uygulama dilinde gösterilen eylem sonuçlarıyla kaydeden yerel denetim günlüğü. Kimlik bilgileri ve telefon numaraları gizlenir/maskelenir. --- 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 833bcbc..751d9be 100644 --- a/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt +++ b/app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt @@ -416,6 +416,67 @@ class AutomationRepository(private val context: Context) { return rule } + /** Clones a rule through decrypted domain data so encrypted fields get fresh IVs. */ + suspend fun duplicate( + sourceId: String, + copyName: String, + newId: String = UUID.randomUUID().toString(), + createdAt: Long = System.currentTimeMillis(), + ): Automation? { + var clone: Automation? = null + var createdCloneTtsFile: java.io.File? = null + try { + context.dataStore.edit { prefs -> + migrateHistory(prefs) + val current = prefs[key]?.let { safeDecode(it) } ?: return@edit + val source = current.firstOrNull { it.id == sourceId }?.withDecryptedSecrets() ?: return@edit + val ttsManager = com.flowpilot.app.actions.TtsManager(context) + val cloneTtsFileName = source.ttsAudioFileName.takeIf { it.isNotBlank() }?.let { sourceFileName -> + val sourceFile = ttsManager.getCacheFile(sourceFileName) + val targetFileName = ttsManager.computeCacheFileName( + newId, + source.ttsText, + source.ttsVoiceName, + source.ttsSpeechRate, + ) + val targetFile = ttsManager.getCacheFile(targetFileName) + if (sourceFile?.isFile != true || sourceFile.length() <= 0L || targetFile == null) { + throw java.io.IOException("Source TTS cache file is missing") + } + val targetExisted = targetFile.exists() + try { + sourceFile.copyTo(targetFile, overwrite = false) + createdCloneTtsFile = targetFile + targetFileName + } catch (error: java.io.IOException) { + if (!targetExisted) targetFile.delete() + throw error + } + }.orEmpty() + clone = source.copy( + id = newId, + name = copyName, + enabled = false, + ttsAudioFileName = cloneTtsFileName, + createdAt = createdAt, + lastTriggeredAt = 0L, + ) + prefs[key] = json.encodeToString( + listSerializer, + current.map { it.withEncryptedSecrets() } + clone!!.withEncryptedSecrets(), + ) + } + } catch (error: Throwable) { + createdCloneTtsFile?.delete() + throw error + } + if (clone != null) { + cleanupOrphanTtsFiles() + notifyWidgets() + } + return clone + } + suspend fun update(rule: Automation) { context.dataStore.edit { prefs -> migrateHistory(prefs) diff --git a/app/src/main/java/com/flowpilot/app/ui/AppViewModel.kt b/app/src/main/java/com/flowpilot/app/ui/AppViewModel.kt index 3933415..c943f7f 100644 --- a/app/src/main/java/com/flowpilot/app/ui/AppViewModel.kt +++ b/app/src/main/java/com/flowpilot/app/ui/AppViewModel.kt @@ -48,6 +48,15 @@ data class ManualRunResult( val failureMessages: List, ) +internal suspend fun duplicateRuleResult(duplicate: suspend () -> Automation?): Result = + try { + Result.success(duplicate() ?: error("Source rule no longer exists")) + } catch (cancellation: kotlinx.coroutines.CancellationException) { + throw cancellation + } catch (error: Exception) { + Result.failure(error) + } + class AppViewModel(app: Application) : AndroidViewModel(app) { private val repository = AutomationRepository(app) @@ -369,6 +378,16 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { } } + fun duplicateRule( + source: Automation, + copyName: String, + onResult: (Result) -> Unit, + ) { + viewModelScope.launch { + onResult(duplicateRuleResult { repository.duplicate(source.id, copyName) }) + } + } + fun updateRule(rule: Automation) { viewModelScope.launch { repository.update(rule) diff --git a/app/src/main/java/com/flowpilot/app/ui/screens/HomeScreen.kt b/app/src/main/java/com/flowpilot/app/ui/screens/HomeScreen.kt index 1c8ffbf..88ce0dc 100644 --- a/app/src/main/java/com/flowpilot/app/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/flowpilot/app/ui/screens/HomeScreen.kt @@ -21,8 +21,10 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Share import androidx.compose.material3.* import androidx.compose.runtime.* @@ -31,7 +33,9 @@ import com.flowpilot.app.ui.components.PresetsBottomSheet import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.launch import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.flowpilot.app.R @@ -64,6 +68,9 @@ fun HomeScreen( val engineFailure by vm.engineFailure.collectAsState() val geofenceDiagnostics by vm.geofenceDiagnostics.collectAsState() val geofenceReceiverError = geofenceDiagnostics[AutomationRepository.GEOFENCE_RECEIVER_DIAGNOSTIC_ID] + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() var selectedRuleIds by remember { mutableStateOf>(emptySet()) } var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showPresetsSheet by remember { mutableStateOf(false) } @@ -101,6 +108,7 @@ fun HomeScreen( } Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { @@ -257,6 +265,7 @@ fun HomeScreen( ) { items(rules, key = { it.rule.id }) { item -> val isSelected = item.rule.id in selectedRuleIds + val copyName = stringResource(R.string.rule_copy_name, item.rule.name) RuleCard( item = item, isSelected = isSelected, @@ -272,6 +281,17 @@ fun HomeScreen( selectedRuleIds = if (isSelected) selectedRuleIds - item.rule.id else selectedRuleIds + item.rule.id }, enabled = { vm.setEnabled(item.rule.id, it) }, + onDuplicate = { + vm.duplicateRule(item.rule, copyName) { result -> + result.onSuccess(detail).onFailure { + scope.launch { + snackbarHostState.showSnackbar( + context.getString(R.string.duplicate_rule_failed), + ) + } + } + } + }, onPermission = permissions, ) } @@ -359,9 +379,11 @@ private fun RuleCard( onClick: () -> Unit, onLongClick: () -> Unit, enabled: (Boolean) -> Unit, + onDuplicate: () -> Unit, onPermission: () -> Unit, ) { val isRuleEnabled = item.rule.enabled + var showOverflow by remember { mutableStateOf(false) } val containerColor by animateColorAsState( targetValue = when { isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) @@ -549,7 +571,25 @@ private fun RuleCard( } if (!isSelectionMode) { - Spacer(Modifier.width(10.dp)) + Spacer(Modifier.width(6.dp)) + Box { + IconButton(onClick = { showOverflow = true }) { + Icon(Icons.Default.MoreVert, stringResource(R.string.rule_more_actions)) + } + DropdownMenu( + expanded = showOverflow, + onDismissRequest = { showOverflow = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.btn_duplicate)) }, + leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) }, + onClick = { + showOverflow = false + onDuplicate() + }, + ) + } + } FollowSwitch(isRuleEnabled, enabled) } } diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 3e83137..c90c8ca 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -21,6 +21,10 @@ İptal Sil Düzenle + Çoğalt + Diğer kural işlemleri + %1$s (kopya) + Kural çoğaltılamadı Tamam Kapat Test diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 004a123..68bdc8a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,10 @@ Cancel Delete Edit + Duplicate + More rule actions + %1$s (copy) + Couldn’t duplicate rule OK Close Test diff --git a/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryCryptoTest.kt b/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryCryptoTest.kt index d2a31b4..74f950d 100644 --- a/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryCryptoTest.kt +++ b/app/src/test/java/com/flowpilot/app/data/AutomationRepositoryCryptoTest.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.flowpilot.app.data.model.ActionType import com.flowpilot.app.data.model.Automation +import com.flowpilot.app.actions.TtsManager import com.flowpilot.app.data.model.TriggerEvent import com.flowpilot.app.data.security.SecretCipher import com.google.common.truth.Truth.assertThat @@ -50,6 +51,7 @@ class AutomationRepositoryCryptoTest { @After fun tearDown() = runTest { repository.rawDataStore.edit { it.clear() } + context.filesDir.resolve("tts_cache").deleteRecursively() SecretCipher.secretKeyProvider = null } @@ -176,6 +178,194 @@ class AutomationRepositoryCryptoTest { assertThat(stored.webhookBody).doesNotContain("payload") } + @Test + fun duplicate_copiesCompleteConfiguration_butResetsIdentityAndRuntimeState() = runTest { + val source = repository.add( + name = "Morning", + triggerEvent = TriggerEvent.BATTERY_BELOW, + appPackage = "com.example.app", + appName = "Example", + actions = listOf(ActionType.HTTP_WEBHOOK, ActionType.NFC_ON), + actionDelays = listOf(4, 8), + cooldownMinutes = 30, + scheduledDays = setOf(1, 3, 5), + batteryLevel = 25, + conditions = listOf(com.flowpilot.app.data.model.RuleCondition( + type = com.flowpilot.app.data.model.ConditionType.SCREEN_ON, + )), + webhookMethod = "PATCH", + webhookUrl = "https://example.com/hook?token=secret", + webhookHeaders = "Authorization: Bearer secret", + webhookBody = "{\"secret\":true}", + webhookTimeoutSeconds = 20, + geofenceName = "Home", + geofenceLatitude = 41.0, + geofenceLongitude = 29.0, + geofenceRadiusMeters = 250, + ) + repository.patchLastTriggeredAt(source.id, 9_999L) + repository.recordGeofenceRegistration(listOf(source.id), at = 8_888L) + val persistedSource = repository.automations.first().single() + + val clone = repository.duplicate( + sourceId = source.id, + copyName = "Morning (copy)", + newId = "clone-id", + createdAt = 12_345L, + ) + + assertThat(clone).isEqualTo( + persistedSource.copy( + id = "clone-id", + name = "Morning (copy)", + enabled = false, + createdAt = 12_345L, + lastTriggeredAt = 0L, + ), + ) + assertThat(repository.geofenceDiagnostics.first()["clone-id"]).isNull() + assertThat(repository.automations.first().first { it.id == source.id }).isEqualTo(persistedSource) + } + + @Test + fun duplicate_ttsRule_copiesCacheToCloneOwnedFile() = runTest { + val manager = TtsManager(context) + val sourceId = "source-id" + val cloneId = "clone-id" + val text = "Independent speech" + val voice = "default" + val rate = 1.0f + val sourceFileName = manager.computeCacheFileName(sourceId, text, voice, rate) + val sourceFile = requireNotNull(manager.getCacheFile(sourceFileName)) + sourceFile.writeBytes(byteArrayOf(1, 2, 3, 4)) + val source = repository.add( + name = "Speak", + triggerEvent = TriggerEvent.CHARGER_CONNECTED, + appPackage = "", + appName = "", + actions = listOf(ActionType.SPEAK_TEXT), + ttsText = text, + ttsVoiceName = voice, + ttsSpeechRate = rate, + ttsAudioFileName = sourceFileName, + id = sourceId, + ) + + val clone = requireNotNull(repository.duplicate(source.id, "Speak (copy)", newId = cloneId)) + val expectedFileName = manager.computeCacheFileName(cloneId, text, voice, rate) + val cloneFile = requireNotNull(manager.getCacheFile(expectedFileName)) + + assertThat(clone.ttsAudioFileName).isEqualTo(expectedFileName) + assertThat(cloneFile.readBytes()).isEqualTo(sourceFile.readBytes()) + assertThat(cloneFile.canonicalPath).isNotEqualTo(sourceFile.canonicalPath) + } + + @Test + fun duplicate_ttsRuleWithMissingCache_failsWithoutMutatingSource() = runTest { + val source = repository.add( + name = "Speak", + triggerEvent = TriggerEvent.CHARGER_CONNECTED, + appPackage = "", + appName = "", + actions = listOf(ActionType.SPEAK_TEXT), + ttsText = "Missing speech", + ttsVoiceName = "default", + ttsAudioFileName = "tts_source-id_0123456789abcdef.wav", + id = "source-id", + ) + + val error = runCatching { + repository.duplicate(source.id, "Speak (copy)", newId = "clone-id") + }.exceptionOrNull() + + assertThat(error).isInstanceOf(java.io.IOException::class.java) + assertThat(repository.automations.first()).containsExactly(source) + } + + @Test + fun duplicate_ttsRuleWhenTargetExists_failsWithoutDeletingFilesOrPersistingClone() = runTest { + val manager = TtsManager(context) + val sourceId = "source-id" + val cloneId = "clone-id" + val text = "Independent speech" + val voice = "default" + val rate = 1.0f + val sourceFileName = manager.computeCacheFileName(sourceId, text, voice, rate) + val sourceFile = requireNotNull(manager.getCacheFile(sourceFileName)) + sourceFile.writeBytes(byteArrayOf(1, 2, 3, 4)) + val targetFileName = manager.computeCacheFileName(cloneId, text, voice, rate) + val targetFile = requireNotNull(manager.getCacheFile(targetFileName)) + val source = repository.add( + name = "Speak", + triggerEvent = TriggerEvent.CHARGER_CONNECTED, + appPackage = "", + appName = "", + actions = listOf(ActionType.SPEAK_TEXT), + ttsText = text, + ttsVoiceName = voice, + ttsSpeechRate = rate, + ttsAudioFileName = sourceFileName, + id = sourceId, + ) + targetFile.writeBytes(byteArrayOf(9, 8, 7)) + + val error = runCatching { + repository.duplicate(source.id, "Speak (copy)", newId = cloneId) + }.exceptionOrNull() + + assertThat(error).isInstanceOf(java.io.IOException::class.java) + assertThat(sourceFile.readBytes()).isEqualTo(byteArrayOf(1, 2, 3, 4)) + assertThat(targetFile.readBytes()).isEqualTo(byteArrayOf(9, 8, 7)) + assertThat(repository.automations.first()).containsExactly(source) + } + + @Test + fun duplicate_reencryptsWebhookSecretsWithFreshCiphertext() = runTest { + val source = repository.add( + name = "Webhook", + triggerEvent = TriggerEvent.CHARGER_CONNECTED, + appPackage = "", + appName = "", + actions = listOf(ActionType.HTTP_WEBHOOK), + webhookUrl = "https://example.com/hook?token=secret", + webhookHeaders = "Authorization: Bearer secret", + webhookBody = "{\"secret\":true}", + ) + + repository.duplicate(source.id, "Webhook (copy)", newId = "clone-id", createdAt = 12_345L) + + val stored = json.decodeFromString( + listSerializer, + repository.rawDataStore.data.first()[key]!!, + ) + val storedSource = stored.first { it.id == source.id } + val storedClone = stored.first { it.id == "clone-id" } + assertThat(storedClone.webhookUrl).startsWith("enc:v1:") + assertThat(storedClone.webhookHeaders).startsWith("enc:v1:") + assertThat(storedClone.webhookBody).startsWith("enc:v1:") + assertThat(storedClone.webhookUrl).isNotEqualTo(storedSource.webhookUrl) + assertThat(storedClone.webhookHeaders).isNotEqualTo(storedSource.webhookHeaders) + assertThat(storedClone.webhookBody).isNotEqualTo(storedSource.webhookBody) + assertThat(repository.automations.first().first { it.id == "clone-id" }.webhookUrl) + .isEqualTo("https://example.com/hook?token=secret") + } + + @Test + fun duplicate_unknownRule_doesNotMutateRepository() = runTest { + val source = repository.add( + name = "Only rule", + triggerEvent = TriggerEvent.CHARGER_CONNECTED, + appPackage = "", + appName = "", + actions = listOf(ActionType.NFC_ON), + ) + + val clone = repository.duplicate("missing", "Missing (copy)") + + assertThat(clone).isNull() + assertThat(repository.automations.first()).containsExactly(source) + } + @Test fun update_webhookRule_persistsEncrypted() = runTest { val rule = repository.add( diff --git a/app/src/test/java/com/flowpilot/app/ui/DuplicateRuleResultTest.kt b/app/src/test/java/com/flowpilot/app/ui/DuplicateRuleResultTest.kt new file mode 100644 index 0000000..73441a6 --- /dev/null +++ b/app/src/test/java/com/flowpilot/app/ui/DuplicateRuleResultTest.kt @@ -0,0 +1,52 @@ +package com.flowpilot.app.ui + +import com.flowpilot.app.data.model.Automation +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DuplicateRuleResultTest { + + @Test + fun duplicateRuleResult_missingSource_returnsFailure() = runTest { + val result = duplicateRuleResult { null } + + assertThat(result.isFailure).isTrue() + } + + @Test + fun duplicateRuleResult_repositoryError_returnsFailure() = runTest { + val result = duplicateRuleResult { error("write failed") } + + assertThat(result.exceptionOrNull()).hasMessageThat().isEqualTo("write failed") + } + + @Test + fun duplicateRuleResult_cancellation_rethrows() = runTest { + val cancellation = kotlinx.coroutines.CancellationException("cancelled") + + val thrown = runCatching { + duplicateRuleResult { throw cancellation } + }.exceptionOrNull() + + assertThat(thrown).isSameInstanceAs(cancellation) + } + + @Test + fun duplicateRuleResult_seriousError_rethrows() = runTest { + val seriousError = AssertionError("serious") + + val thrown = runCatching { + duplicateRuleResult { throw seriousError } + }.exceptionOrNull() + + assertThat(thrown).isSameInstanceAs(seriousError) + } + + @Test + fun duplicateRuleResult_createdClone_returnsSuccess() = runTest { + val clone = Automation(id = "clone-id", name = "Copy", createdAt = 1L) + + assertThat(duplicateRuleResult { clone }.getOrNull()).isEqualTo(clone) + } +} diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 7249c17..b00c6b3 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -16,6 +16,8 @@ Actions can have a per-action pre-execution delay of 0-300 seconds. Configured a Rules can have a 0, 1, 5, 15, or 60-minute cooldown. Cooldown applies to all automatic trigger evaluators after a successful run updates `lastTriggeredAt`; manual test runs bypass it. A future `lastTriggeredAt` blocks safely until wall clock catches up. +Home list overflow duplication copies the complete rule configuration into a new disabled rule and opens Edit for immediate review. Repository cloning assigns a new UUID and creation timestamp, clears `lastTriggeredAt`, leaves source/history/geofence diagnostics untouched, and decrypts then re-encrypts webhook fields so source and clone never share ciphertext. TTS clones receive an independent cache file; missing or failed source-cache copies abort duplication, remove any newly created target, preserve the source, and report failure in Home. Existing sanitized export and encrypted backup paths are unchanged. + Wi-Fi rules persist only user-selected SSIDs. Users may type an SSID or request a one-shot nearby-network scan; scan results are transient, deduplicated, and never persisted. Android throttles scan frequency and may return cached results. The tracker reads SSID from Wi-Fi-specific `NetworkCallback` capabilities instead of `activeNetwork`, so Xiaomi can detect Wi-Fi transitions even when cellular remains the default data network. ## Capability matrix (verified against Android 16 / HyperOS constraints) diff --git a/docs/STATUS.md b/docs/STATUS.md index 66bc0e0..007802c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -40,6 +40,7 @@ Last updated: 2026-09-14 ## Implemented; device validation pending +- Safe rule duplication from the Home list overflow menu: creates a disabled copy with a new UUID/creation time, resets `lastTriggeredAt` and transient registration state, preserves complete configuration, re-encrypts webhook secrets with fresh Android Keystore ciphertext, and opens the copy in Edit immediately. Kotlin unit/build/device verification pending; SDK-free static/resource contracts passed. - Time Window (`TIME_BETWEEN`) and Days of the Week (`DAYS_OF_WEEK`) conditions (unit tests passed; device smoke tests pending): - Time interval filtering with overnight span support (e.g. 23:00 - 07:00 crossing midnight). - Day of week filtering with Daily, Weekdays, Weekends, and custom day toggles. diff --git a/scripts/check_duplicate_result_contract.py b/scripts/check_duplicate_result_contract.py new file mode 100644 index 0000000..229783b --- /dev/null +++ b/scripts/check_duplicate_result_contract.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +from pathlib import Path + +root = Path(__file__).resolve().parents[1] +source = (root / "app/src/main/java/com/flowpilot/app/ui/AppViewModel.kt").read_text() +test = (root / "app/src/test/java/com/flowpilot/app/ui/DuplicateRuleResultTest.kt").read_text() + +assert "duplicateRuleResult_missingSource_returnsFailure" in test +assert "duplicateRuleResult_repositoryError_returnsFailure" in test +assert "duplicateRuleResult_cancellation_rethrows" in test +assert "duplicateRuleResult_seriousError_rethrows" in test +assert "duplicateRuleResult_createdClone_returnsSuccess" in test +assert 'Automation(id = "clone-id", name = "Copy", createdAt = 1L)' in test +assert "suspend fun duplicateRuleResult" in source +assert "catch (cancellation: kotlinx.coroutines.CancellationException)" in source +assert "throw cancellation" in source +assert "catch (error: Exception)" in source +assert "catch (error: Throwable)" not in source[source.index("suspend fun duplicateRuleResult"):source.index("class AppViewModel")] +assert "onResult: (Result) -> Unit" in source +home = (root / "app/src/main/java/com/flowpilot/app/ui/screens/HomeScreen.kt").read_text() +assert "duplicate_rule_failed" in home +assert "SnackbarHost(snackbarHostState)" in home +print("Duplicate result contract OK") diff --git a/scripts/check_tts_clone_contract.py b/scripts/check_tts_clone_contract.py new file mode 100644 index 0000000..66abfa3 --- /dev/null +++ b/scripts/check_tts_clone_contract.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +from pathlib import Path + +root = Path(__file__).resolve().parents[1] +repo = (root / "app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt").read_text() +tests = (root / "app/src/test/java/com/flowpilot/app/data/AutomationRepositoryCryptoTest.kt").read_text() + +assert "duplicate_ttsRule_copiesCacheToCloneOwnedFile" in tests +assert "duplicate_ttsRuleWithMissingCache_failsWithoutMutatingSource" in tests +target_exists_test = tests[ + tests.index("fun duplicate_ttsRuleWhenTargetExists_failsWithoutDeletingFilesOrPersistingClone"): + tests.index("fun duplicate_reencryptsWebhookSecretsWithFreshCiphertext") +] +assert target_exists_test.index("val source = repository.add(") < target_exists_test.index("targetFile.writeBytes(") +assert "computeCacheFileName(\n newId," in repo +assert "createdCloneTtsFile" in repo +assert "createdCloneTtsFile?.delete()" in repo +assert "copyTo(targetFile, overwrite = false)" in repo +assert "ttsAudioFileName = cloneTtsFileName" in repo +assert "if (!targetExisted) targetFile.delete()" in repo +print("TTS clone contract OK") diff --git a/scripts/test_rule_duplicate_contracts.py b/scripts/test_rule_duplicate_contracts.py new file mode 100644 index 0000000..e3dda30 --- /dev/null +++ b/scripts/test_rule_duplicate_contracts.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Static contracts for safe rule duplication; no Android SDK required.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = (ROOT / "app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt").read_text() +VIEW_MODEL = (ROOT / "app/src/main/java/com/flowpilot/app/ui/AppViewModel.kt").read_text() +APP = (ROOT / "app/src/main/java/com/flowpilot/app/ui/App.kt").read_text() +HOME = (ROOT / "app/src/main/java/com/flowpilot/app/ui/screens/HomeScreen.kt").read_text() + + +def require(text: str, needle: str) -> None: + assert needle in text, f"missing contract: {needle}" + + +for reset in ( + "id = newId", + "name = copyName", + "enabled = false", + "createdAt = createdAt", + "lastTriggeredAt = 0L", +): + require(REPOSITORY, reset) +require(REPOSITORY, "firstOrNull { it.id == sourceId }?.withDecryptedSecrets()") +require(REPOSITORY, "clone!!.withEncryptedSecrets()") +for repository_contract in ( + "throw java.io.IOException(\"Source TTS cache file is missing\")", + "catch (error: java.io.IOException)", + "throw error", + "catch (error: Throwable)", +): + require(REPOSITORY, repository_contract) +duplicate_body = REPOSITORY[REPOSITORY.index("suspend fun duplicate("):REPOSITORY.index("suspend fun update(")] +assert duplicate_body.count("throw error") == 2, "duplicate must rethrow copy I/O and outer cancellation/serious failures" +assert "catch (_: Throwable)" not in REPOSITORY[REPOSITORY.index("suspend fun duplicate("):REPOSITORY.index("suspend fun update(")] +for view_model_contract in ( + "fun duplicateRule(", + "source: Automation", + "copyName: String", + "onResult: (Result) -> Unit", + "onResult(duplicateRuleResult { repository.duplicate(source.id, copyName) })", +): + require(VIEW_MODEL, view_model_contract) +require(HOME, "vm.duplicateRule(item.rule, copyName) { result ->") +require(HOME, "result.onSuccess(detail).onFailure") +require(HOME, "duplicate_rule_failed") +require(HOME, "R.string.btn_duplicate") +require(APP, "detail = { selectedRule = it; page = Page.DETAIL }") +print("rule duplicate static contracts: OK")