Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
1 change: 1 addition & 0 deletions README.tr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
61 changes: 61 additions & 0 deletions app/src/main/java/com/flowpilot/app/data/AutomationRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/com/flowpilot/app/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ data class ManualRunResult(
val failureMessages: List<String>,
)

internal suspend fun duplicateRuleResult(duplicate: suspend () -> Automation?): Result<Automation> =
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)
Expand Down Expand Up @@ -369,6 +378,16 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
}
}

fun duplicateRule(
source: Automation,
copyName: String,
onResult: (Result<Automation>) -> Unit,
) {
viewModelScope.launch {
onResult(duplicateRuleResult { repository.duplicate(source.id, copyName) })
}
}

fun updateRule(rule: Automation) {
viewModelScope.launch {
repository.update(rule)
Expand Down
42 changes: 41 additions & 1 deletion app/src/main/java/com/flowpilot/app/ui/screens/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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
Expand Down Expand Up @@ -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<Set<String>>(emptySet()) }
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
var showPresetsSheet by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -101,6 +108,7 @@ fun HomeScreen(
}

Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-tr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
<string name="btn_cancel">İptal</string>
<string name="btn_delete">Sil</string>
<string name="btn_edit">Düzenle</string>
<string name="btn_duplicate">Çoğalt</string>
<string name="rule_more_actions">Diğer kural işlemleri</string>
<string name="rule_copy_name">%1$s (kopya)</string>
<string name="duplicate_rule_failed">Kural çoğaltılamadı</string>
<string name="btn_ok">Tamam</string>
<string name="btn_close">Kapat</string>
<string name="btn_test">Test</string>
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
<string name="btn_cancel">Cancel</string>
<string name="btn_delete">Delete</string>
<string name="btn_edit">Edit</string>
<string name="btn_duplicate">Duplicate</string>
<string name="rule_more_actions">More rule actions</string>
<string name="rule_copy_name">%1$s (copy)</string>
<string name="duplicate_rule_failed">Couldn’t duplicate rule</string>
<string name="btn_ok">OK</string>
<string name="btn_close">Close</string>
<string name="btn_test">Test</string>
Expand Down
Loading
Loading