diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt index 1b8c87f..517bfc3 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/AgendulaApp.kt @@ -7,15 +7,16 @@ import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.StartupGate import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean /** * Application entry point. Registered as android:name=".AgendulaApp". Besides @@ -43,14 +44,23 @@ class AgendulaApp : Application() { // Mirror the stored storage mode into ProviderResolver and import a // v0.3.x install's tasks, both before anything reads a store. val startupGate = entryPoint.startupGate() + val scope = entryPoint.applicationScope() + // An alarm is armed off whichever store was active when it was scheduled, + // so a switch has to rebuild the set. Armed only once startup's own + // null -> stored transition is past, which the launch sync below covers. + val started = AtomicBoolean(false) + entryPoint.providerResolver().onModeChanged { + if (started.get()) scope.launch { runCatching { scheduler.sync() } } + } startupGate.start() ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint()) - CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { + scope.launch { // Wait for the stored mode and the import to land first. Rescheduling // alarms against whichever store autoMode happens to pick would arm // them off the wrong one — or off an empty one, mid-import. runCatching { startupGate.awaitReady() + started.set(true) scheduler.sync() } } @@ -61,6 +71,10 @@ class AgendulaApp : Application() { interface AppEntryPoint { fun reminderScheduler(): ReminderScheduler fun startupGate(): StartupGate + fun providerResolver(): ProviderResolver + + @ApplicationScope + fun applicationScope(): CoroutineScope fun databaseCheckpoint(): DatabaseCheckpoint } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt index 0376c58..03f43d5 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt @@ -17,8 +17,23 @@ import javax.inject.Singleton /** Where an export ended up, for the UI to report. */ data class ExportResult(val fileCount: Int, val taskListNames: List) -/** The export could not be written. Carries a cause worth showing a user. */ -class ExportFailedException(message: String, cause: Throwable? = null) : IOException(message, cause) +/** + * Why an export failed, as a value rather than a message: the UI ships in eleven + * locales, so the wording has to come from a string resource. + */ +enum class ExportFailure { + FOLDER_UNAVAILABLE, + FOLDER_NOT_WRITABLE, + CANNOT_CREATE_FILE, + LOST_ACCESS, + WRITE_FAILED, +} + +/** The export could not be written. */ +class ExportFailedException( + val failure: ExportFailure, + cause: Throwable? = null, +) : IOException(failure.name, cause) /** * Writes [ExportDocument]s to a user-chosen location through the Storage Access @@ -44,22 +59,30 @@ class ExportWriter @Inject constructor( /** * Writes every document into [treeUri], a directory the user picked. * - * Overwrites same-named files rather than letting SAF append " (1)" — an - * export is a snapshot, and silently accumulating `Groceries-3 (4).ics` makes - * the folder useless as a backup. + * A same-named file is truncated and rewritten in place rather than deleted + * and recreated: SAF would otherwise append " (1)" and turn the folder into + * an unusable pile of snapshots, and a delete that is not followed by a + * successful create loses the previous export outright. + * + * The directory is listed once. `DocumentFile.findFile` queries the whole + * tree per call, so looking each name up in the loop is one full + * cross-process directory scan per list. */ suspend fun writeToTree(treeUri: Uri, documents: List): ExportResult = withContext(io) { - val tree = DocumentFile.fromTreeUri(context, treeUri) - ?: throw ExportFailedException("Cannot open the chosen folder") - if (!tree.canWrite()) throw ExportFailedException("The chosen folder is not writable") + runCatching { + val tree = DocumentFile.fromTreeUri(context, treeUri) + ?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE) + if (!tree.canWrite()) throw ExportFailedException(ExportFailure.FOLDER_NOT_WRITABLE) + val existing = tree.listFiles().associateBy { it.name } - documents.forEach { document -> - tree.findFile(document.fileName)?.delete() - val file = tree.createFile(MIME_ICALENDAR, document.fileName) - ?: throw ExportFailedException("Cannot create ${document.fileName}") - write(file.uri, document.content) - } + documents.forEach { document -> + val file = existing[document.fileName] + ?: tree.createFile(MIME_ICALENDAR, document.fileName) + ?: throw ExportFailedException(ExportFailure.CANNOT_CREATE_FILE) + write(file.uri, document.content) + } + }.getOrElse { throw asExportFailure(it) } ExportResult(documents.size, documents.map { it.fileName }) } @@ -80,7 +103,7 @@ class ExportWriter @Inject constructor( zip.closeEntry() } } - } ?: throw ExportFailedException("Cannot write to the chosen file") + } ?: throw ExportFailedException(ExportFailure.WRITE_FAILED) }.getOrElse { throw asExportFailure(it) } ExportResult(documents.size, documents.map { it.fileName }) } @@ -90,7 +113,7 @@ class ExportWriter @Inject constructor( // "wt" truncates. Without it a shorter export leaves the tail of the // previous, longer one behind and produces a corrupt file. context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) } - ?: throw ExportFailedException("Cannot write to the chosen file") + ?: throw ExportFailedException(ExportFailure.WRITE_FAILED) }.getOrElse { throw asExportFailure(it) } } @@ -98,8 +121,8 @@ class ExportWriter @Inject constructor( is ExportFailedException -> cause // A SAF grant can be revoked between the picker and the write (the volume // was unmounted, the provider's process died, the user cleared the grant). - is SecurityException -> ExportFailedException("Lost access to the chosen location", cause) - is IOException -> ExportFailedException(cause.message ?: "Could not write the export", cause) + is SecurityException -> ExportFailedException(ExportFailure.LOST_ACCESS, cause) + is IOException -> ExportFailedException(ExportFailure.WRITE_FAILED, cause) else -> cause } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt index 089ee9d..58467e0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt @@ -12,6 +12,8 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -20,8 +22,8 @@ import javax.inject.Singleton * The self-scheduled due-reminder engine. Nothing else delivers task reminders — * not the platform, not a tasks provider — so Agendula reads upcoming due tasks * and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run - * on app start, on boot, and on an external provider change; it diffs against - * [ScheduledReminderStore] so only changed alarms move. + * on app start, on boot, on a store switch, and on an external provider change; + * it diffs against [ScheduledReminderStore] so only changed alarms move. */ @Singleton class ReminderScheduler @Inject constructor( @@ -32,19 +34,32 @@ class ReminderScheduler @Inject constructor( private val providerResolver: ProviderResolver, @IoDispatcher private val io: CoroutineDispatcher, ) { - suspend fun sync() = withContext(io) { + private val syncLock = Mutex() + + /** + * Diff the armed alarms against the store and move only what changed. + * + * Serialised: the diff is a read-modify-write over [ScheduledReminderStore], + * and callers overlap (a store switch fires this while the launch sync may + * still be running). Two interleaved runs would each write their own set as + * the whole truth, leaving the other's alarms armed but unrecorded — never + * cancelled, and firing against the wrong store's task ids. + */ + suspend fun sync() = withContext(io) { syncLock.withLock { syncLocked() } } + + private suspend fun syncLocked() { val settings = settingsPrefs.settings.first() // Gate on whether the store is readable, not on whether a provider // resolves: our own store deliberately resolves to no provider, so the // latter clears every reminder in the default mode. if (!settings.remindersEnabled || !providerResolver.canReadStore()) { clearAll() - return@withContext + return } val now = System.currentTimeMillis() val horizon = now + WINDOW_MS val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } - .getOrElse { return@withContext } + .getOrElse { return } // One reminder per *occurrence*: a recurring series yields a row per // occurrence, all sharing a taskId, so this is a Set rather than a diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt index 4cfca57..e60193f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt @@ -23,6 +23,9 @@ interface ProviderEnvironment { /** Whether this app currently holds [permission]. */ fun isGranted(permission: String): Boolean + + /** [packageName]'s own app name, or null when it cannot be read. */ + fun appLabel(packageName: String): String? } @Singleton @@ -35,4 +38,9 @@ class AndroidProviderEnvironment @Inject constructor( override fun isGranted(permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + override fun appLabel(packageName: String): String? = runCatching { + val pm = context.packageManager + pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() + }.getOrNull() } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt index fc41b08..3154095 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt @@ -10,19 +10,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel @@ -46,20 +44,20 @@ fun RootScreen( // Re-check on every resume, not just after the in-app request: the user may // have granted the permission (or installed a provider) in system Settings and // come back, and otherwise the gate would hold until the process restarts. - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) permissionViewModel.refresh() - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } + OnResume { permissionViewModel.refresh() } + + // Neither gate can show in OWN mode (that store is always READY), so the way + // out of one is always our own store. Without it a user whose provider app + // went away is held on this screen with Settings behind it. + val fallback = stringResource(R.string.onboarding_use_own_store) when (permission.status) { ProviderStatus.NO_PROVIDER -> Gate( modifier = modifier, title = stringResource(R.string.onboarding_no_provider_title), body = stringResource(R.string.onboarding_no_provider_body), + secondaryAction = fallback, + onSecondaryAction = permissionViewModel::useOwnStore, ) ProviderStatus.NEEDS_PERMISSION -> Gate( modifier = modifier, @@ -67,6 +65,8 @@ fun RootScreen( body = stringResource(R.string.onboarding_permission_body), action = stringResource(R.string.onboarding_permission_button), onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, + secondaryAction = fallback, + onSecondaryAction = permissionViewModel::useOwnStore, ) ProviderStatus.READY -> ReadyGate(modifier = modifier) } @@ -103,6 +103,8 @@ private fun Gate( modifier: Modifier = Modifier, action: String? = null, onAction: () -> Unit = {}, + secondaryAction: String? = null, + onSecondaryAction: () -> Unit = {}, ) { Column( modifier = modifier.fillMaxSize().padding(24.dp), @@ -112,5 +114,6 @@ private fun Gate( Text(title, style = MaterialTheme.typography.headlineSmall) Text(body, style = MaterialTheme.typography.bodyMedium) if (action != null) Button(onClick = onAction) { Text(action) } + if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt new file mode 100644 index 0000000..b29f473 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner + +/** + * Runs [block] on every `ON_RESUME`. + * + * For state the app cannot observe because it is granted, revoked or installed + * outside it — a runtime permission, an exact-alarm allowance, a provider app — + * which otherwise stays stale until the process restarts. + */ +@Composable +fun OnResume(block: () -> Unit) { + val current by rememberUpdatedState(block) + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) current() + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt new file mode 100644 index 0000000..c9ae0f1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt @@ -0,0 +1,182 @@ +package de.jeanlucmakiola.agendula.ui.export + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.FolderZip +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.export.ExportFailure +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.floret.components.positionOf + +private const val ZIP_MIME = "application/zip" +private const val ZIP_NAME = "agendula-tasks.zip" + +/** + * Export the task lists as iCalendar. Which lists go out is a per-list tick; the + * destination is a folder or a single zip, both picked through SAF so the app + * needs no storage permission. + */ +@Composable +fun ExportScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ExportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val dark = isSystemInDarkTheme() + + val folderLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> uri?.let(viewModel::exportToFolder) } + val zipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(ZIP_MIME), + ) { uri -> uri?.let(viewModel::exportToZip) } + + val canExport = !state.running && state.selectedCount > 0 + + CollapsingScaffold( + title = stringResource(R.string.settings_export), + onBack = onBack, + modifier = modifier, + ) { + Text( + text = stringResource(R.string.export_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(16.dp)) + + if (state.lists.isEmpty()) { + GroupedRow( + title = stringResource(R.string.export_no_lists), + position = Position.Alone, + dimmed = true, + ) + } else { + state.lists.forEachIndexed { index, list -> + val selected = state.isSelected(list.id) + GroupedRow( + title = list.name, + // The account only says something when it isn't the device itself. + summary = list.accountName.takeIf { !list.isLocal }, + position = positionOf(index, state.lists.size), + leading = { + Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) + }, + trailing = { + Checkbox(checked = selected, onCheckedChange = { viewModel.toggle(list.id) }) + }, + onClick = { viewModel.toggle(list.id) }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { folderLauncher.launch(null) }, + enabled = canExport, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Rounded.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.export_to_folder)) + } + OutlinedButton( + onClick = { zipLauncher.launch(ZIP_NAME) }, + enabled = canExport, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Rounded.FolderZip, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.export_to_zip)) + } + } + + Spacer(Modifier.height(16.dp)) + ExportStatus(state = state) + Spacer(Modifier.height(24.dp)) + } +} + +/** The running spinner, then whatever the last export ended as — it stays put. */ +@Composable +private fun ExportStatus(state: ExportUiState) { + val outcome = state.outcome + when { + state.running -> Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(Modifier.size(18.dp)) + Text( + text = stringResource(R.string.export_running), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + outcome is ExportOutcome.Success -> StatusText( + text = pluralStringResource(R.plurals.export_done, outcome.fileCount, outcome.fileCount), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + outcome is ExportOutcome.Failure -> StatusText( + text = stringResource(failureMessage(outcome.reason)), + color = MaterialTheme.colorScheme.error, + ) + } +} + +private fun failureMessage(reason: ExportFailure): Int = when (reason) { + ExportFailure.FOLDER_UNAVAILABLE -> R.string.export_failed_folder + ExportFailure.FOLDER_NOT_WRITABLE -> R.string.export_failed_read_only + ExportFailure.CANNOT_CREATE_FILE -> R.string.export_failed_create + ExportFailure.LOST_ACCESS -> R.string.export_failed_access + ExportFailure.WRITE_FAILED -> R.string.export_failed +} + +@Composable +private fun StatusText(text: String, color: Color) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = color, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt new file mode 100644 index 0000000..f091b9c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt @@ -0,0 +1,122 @@ +package de.jeanlucmakiola.agendula.ui.export + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.export.ExportFailedException +import de.jeanlucmakiola.agendula.data.export.ExportFailure +import de.jeanlucmakiola.agendula.data.export.ExportResult +import de.jeanlucmakiola.agendula.data.export.ExportWriter +import de.jeanlucmakiola.agendula.data.export.TaskExporter +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportDocument +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +/** How the last export ended, kept on screen rather than flashed past. */ +sealed interface ExportOutcome { + data class Success(val fileCount: Int) : ExportOutcome + data class Failure(val reason: ExportFailure) : ExportOutcome +} + +data class ExportUiState( + val lists: List = emptyList(), + /** Lists the user has ticked *off*; everything else is included. */ + val excluded: Set = emptySet(), + val running: Boolean = false, + val outcome: ExportOutcome? = null, +) { + fun isSelected(listId: Long): Boolean = listId !in excluded + + val selectedCount: Int get() = lists.count { isSelected(it.id) } +} + +/** + * Drives the export screen. Holds the selection as an exclusion set so a list + * that appears while the screen is open is exported too — the natural reading of + * "everything, minus what I unticked". + */ +@HiltViewModel +class ExportViewModel @Inject constructor( + repository: TasksRepository, + resolver: ProviderResolver, + private val exporter: TaskExporter, + private val writer: ExportWriter, +) : ViewModel() { + + private val excluded = MutableStateFlow(emptySet()) + private val running = MutableStateFlow(false) + private val outcome = MutableStateFlow(null) + + private var exportJob: Job? = null + + // List ids are per-store, and Settings can switch stores with this ViewModel + // still alive — so the selection, the receipt and a write already addressing + // the old store's lists all go with it. + private val modeHandle = resolver.onModeChanged { + exportJob?.cancel() + excluded.value = emptySet() + outcome.value = null + } + + override fun onCleared() { + modeHandle.close() + } + + val state: StateFlow = + combine( + repository.taskLists().recoveringFromProviderFailure { emptyList() }, + excluded, + running, + outcome, + ) { lists, excluded, running, outcome -> + ExportUiState(lists, excluded, running, outcome) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ExportUiState()) + + fun toggle(listId: Long) = excluded.update { current -> + if (listId in current) current - listId else current + listId + } + + /** Writes one `.ics` per list into a folder the user picked through SAF. */ + fun exportToFolder(tree: Uri) = export { documents -> writer.writeToTree(tree, documents) } + + /** Writes every list into a single zip the user named through SAF. */ + fun exportToZip(target: Uri) = export { documents -> writer.writeZip(target, documents) } + + private fun export(write: suspend (List) -> ExportResult) { + if (running.value) return + running.value = true + outcome.value = null + exportJob = viewModelScope.launch { + // Null means "every list" to the exporter, and is what an untouched + // screen should send: the flow may not have emitted a list yet. + val selection = excluded.value.takeIf { it.isNotEmpty() } + ?.let { skipped -> state.value.lists.map { it.id }.toSet() - skipped } + try { + val result = write(exporter.export(selection)) + outcome.value = ExportOutcome.Success(result.fileCount) + } catch (cancelled: CancellationException) { + // Leaving the screen mid-write is not a failed export. + throw cancelled + } catch (error: Exception) { + outcome.value = ExportOutcome.Failure( + (error as? ExportFailedException)?.failure ?: ExportFailure.WRITE_FAILED, + ) + } finally { + running.value = false + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt index 4cdd0cf..5c1e89c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt @@ -1,13 +1,25 @@ package de.jeanlucmakiola.agendula.ui.permission import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus +import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject data class PermissionUiState( @@ -21,25 +33,48 @@ data class PermissionUiState( * The Composable owns the actual permission-launcher and store intents; this VM * supplies the [status] and the exact permission strings to ask for. * - * In the default Local mode this gate never appears at all — Agendula's own - * provider ships in the APK and is reached same-uid, so there is nothing to - * install and nothing to grant. It exists for External mode. + * In the default Own mode this gate never appears at all — the store is our own + * Room database, so there is nothing to install and nothing to grant. It exists + * for External mode, which also makes it the only screen an External user can + * reach once their provider app stops answering: hence [useOwnStore]. */ @HiltViewModel class PermissionViewModel @Inject constructor( private val repository: TasksRepository, private val providerResolver: ProviderResolver, + private val prefs: SettingsPrefs, ) : ViewModel() { - private val _state = MutableStateFlow(PermissionUiState()) - val state: StateFlow = _state.asStateFlow() + private val refreshes = MutableStateFlow(0) - init { refresh() } + // Re-evaluated when the *resolver's* mode lands, not when the preference is + // written: anything read in between still answers for the store we just left. + // Conflated, as everywhere else this signal is bridged: only the latest mode + // matters, and a full buffer drops it rather than the ones it supersedes. + private val modeChanges: Flow = callbackFlow { + trySend(Unit) + val handle = providerResolver.onModeChanged { trySend(Unit) } + awaitClose { handle.close() } + }.buffer(Channel.CONFLATED) + + val state: StateFlow = + combine(refreshes, modeChanges) { _, _ -> currentState() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), currentState()) /** Re-read provider + permission state (call after returning from a request). */ - fun refresh() { + fun refresh() = refreshes.update { it + 1 } + + /** + * Leave a store this device can no longer read. The provider app can be + * uninstalled, or its permission revoked, after External was chosen — and the + * gate is then the only screen reachable, Settings included. Our own store + * always reads, so it is the way out. + */ + fun useOwnStore() = viewModelScope.launch { prefs.setStorageMode(StorageMode.OWN) } + + private fun currentState(): PermissionUiState { val provider = providerResolver.resolve() - _state.value = PermissionUiState( + return PermissionUiState( status = repository.providerStatus(), // Null in OWN mode, where there is no provider and nothing to grant. permissionsToRequest = provider diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index 712a61d..5c6da54 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.material.icons.rounded.AccountTree import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Percent +import androidx.compose.material.icons.rounded.Storage import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -56,7 +57,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -75,13 +75,11 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.domain.TaskFormField +import de.jeanlucmakiola.agendula.ui.export.ExportScreen import de.jeanlucmakiola.floret.components.AboutCard import de.jeanlucmakiola.floret.components.AboutLink import de.jeanlucmakiola.floret.components.CollapsingScaffold @@ -96,10 +94,22 @@ import de.jeanlucmakiola.floret.locale.AppLanguage import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.reminderOverrideFor +import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel /** The settings sub-screens reached from the hub's category rows. */ -private enum class SettingsSection { Appearance, TaskForm, Reminders } +private enum class SettingsSection { + Appearance, + TaskForm, + Reminders, + Storage, + Export, + ; + + /** Where back goes: Export is opened from Storage, not from the hub. */ + val parent: SettingsSection? + get() = if (this == Export) Storage else null +} /** * Token-based accent for a leading icon chip (container / on-container pair), @@ -125,7 +135,7 @@ fun SettingsScreen( // Inside a sub-screen, system back (button or gesture) returns to the hub // rather than popping the whole Settings destination to the lists overview. - BackHandler(enabled = section != null) { section = null } + BackHandler(enabled = section != null) { section = section?.parent } Box( modifier = modifier @@ -143,6 +153,19 @@ fun SettingsScreen( SlideInSection(visible = section == SettingsSection.Reminders) { RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null }) } + // Storage stays composed under Export, so the deeper screen slides over it. + val storageOpen = section == SettingsSection.Storage || + section?.parent == SettingsSection.Storage + SlideInSection(visible = storageOpen) { + StorageScreen( + viewModel = viewModel, + onOpenExport = { section = SettingsSection.Export }, + onBack = { section = null }, + ) + } + SlideInSection(visible = section == SettingsSection.Export) { + ExportScreen(onBack = { section = SettingsSection.Storage }) + } } } @@ -212,6 +235,13 @@ private fun SettingsHub( leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, onClick = { onOpenSection(SettingsSection.Reminders) }, ) + GroupedRow( + title = stringResource(R.string.settings_section_storage), + summary = stringResource(R.string.settings_storage_subtitle), + position = Position.Middle, + leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) }, + onClick = { onOpenSection(SettingsSection.Storage) }, + ) LanguageRow(position = Position.Middle) ReportProblemRow(position = Position.Bottom) @@ -670,15 +700,10 @@ private fun rememberExactAlarmAllowed(context: Context): Boolean { context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(), ) } - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - val obs = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() - } + OnResume { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() } - lifecycle.addObserver(obs) - onDispose { lifecycle.removeObserver(obs) } } return allowed } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index e8d0614..b607be0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -3,18 +3,27 @@ package de.jeanlucmakiola.agendula.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TaskProvider import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.floret.reminders.ReminderOverride +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -23,6 +32,22 @@ data class SettingsUiState( val lists: List = emptyList(), ) +/** + * The storage half of Settings: which store is active, and what picking the + * other one would mean on this device. + * + * Kept apart from [SettingsUiState] because that one is collected for the whole + * Activity lifetime to drive the theme, and re-probing PackageManager on every + * theme emission would be work for nothing. + */ +data class StorageUiState( + val mode: StorageMode, + /** The external provider installed here, or null when there is none to pick. */ + val external: TaskProvider? = null, + /** That provider's own app name, for a row that names what it is switching to. */ + val externalLabel: String? = null, +) + /** * Drives both the Settings screen and the app theme (MainActivity collects the * same instance), so a theme change applies app-wide at once. @@ -30,6 +55,9 @@ data class SettingsUiState( @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, + private val resolver: ProviderResolver, + private val environment: ProviderEnvironment, + @IoDispatcher io: CoroutineDispatcher, repository: TasksRepository, ) : ViewModel() { @@ -44,6 +72,31 @@ class SettingsViewModel @Inject constructor( SettingsUiState(settings, lists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) + // Bumped to re-probe the device; installing a provider or granting its + // permission happens outside the app, so nothing else would emit. + private val providerProbe = MutableStateFlow(0) + + // Null until the first emission lands: the mode comes from DataStore and the + // rest from PackageManager, off the main thread, so any seeded default would + // name the wrong store for the first frames. flowOn, because every field here + // costs a PackageManager lookup or a permission check. + val storage: StateFlow = + combine(prefs.storageMode, providerProbe) { stored, _ -> + val external = resolver.resolveExternal() + StorageUiState( + // No stored choice is the normal state; show what autoMode resolves + // to rather than a default that may not be the store in use. + mode = stored ?: resolver.autoMode(), + external = external, + externalLabel = external?.packageName?.let(environment::appLabel), + ) + }.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** Re-read the device's provider state, after a permission request or a resume. */ + fun refreshStorage() = providerProbe.update { it + 1 } + + fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) } + fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt new file mode 100644 index 0000000..bc7c494 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt @@ -0,0 +1,203 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TaskProvider +import de.jeanlucmakiola.agendula.ui.common.OnResume +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck + +/** + * Where the tasks live: the store picker the resolver's `autoMode()` has always + * assumed, plus the way out of a store that lives in our own private storage. + */ +@Composable +internal fun StorageScreen( + viewModel: SettingsViewModel, + onOpenExport: () -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + val storage by viewModel.storage.collectAsStateWithLifecycle() + var showPicker by remember { mutableStateOf(false) } + var denied by remember { mutableStateOf(false) } + + // The mode is committed only once the grant is in — switching first drops the + // user on the app-wide permission gate. + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { grants -> + viewModel.refreshStorage() + val granted = grants.isNotEmpty() && grants.values.all { it } + denied = !granted + if (granted) viewModel.setStorageMode(StorageMode.EXTERNAL) + } + + // A provider can be installed, or its permission revoked, while we're away. + // Deliberately does not clear [denied]: this fires on returning from the + // permission dialog too, and would wipe the refusal before it is read. + OnResume { viewModel.refreshStorage() } + + CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) { + GroupedRow( + title = stringResource(R.string.settings_task_store), + summary = storage?.let { storeLabel(it) }, + position = Position.Top, + // Nothing to pick until the stored mode has landed; opening the picker + // on the seeded state would offer the wrong store as the current one. + onClick = storage?.let { + { + denied = false + showPicker = true + } + }, + ) + GroupedRow( + title = stringResource(R.string.settings_export), + summary = stringResource(R.string.settings_export_hint), + position = Position.Bottom, + onClick = onOpenExport, + ) + + if (denied) { + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.settings_store_permission_denied), + summary = stringResource(R.string.settings_store_permission_denied_hint), + position = Position.Alone, + onClick = { context.openAppSettings() }, + ) + } + } + + storage?.let { state -> + if (showPicker) { + StorePicker( + storage = state, + onSelect = { mode -> + val external = state.external + if (mode == StorageMode.EXTERNAL && external != null) { + // Asked even when the grant looks held: an already-granted + // request returns at once, a stale belief would strand them. + permissionLauncher.launch( + arrayOf(external.readPermission, external.writePermission), + ) + } else { + viewModel.setStorageMode(mode) + } + }, + onDismiss = { showPicker = false }, + ) + } + } +} + +/** + * The two stores, as rows. External is offered only when a provider is actually + * installed — dimmed and inert otherwise, because a mode with nothing behind it + * empties the app. + */ +@Composable +private fun StorePicker( + storage: StorageUiState, + onSelect: (StorageMode) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker(title = stringResource(R.string.settings_task_store), onDismiss = onDismiss) { + Text( + text = stringResource(R.string.settings_task_store_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + val select: (StorageMode) -> Unit = { chosen -> + onSelect(chosen) + onDismiss() + } + val external = storage.external + GroupedRow( + title = stringResource(R.string.settings_store_own), + summary = stringResource(R.string.settings_store_own_hint), + position = Position.Top, + selected = storage.mode == StorageMode.OWN, + leading = { Icon(Icons.Rounded.PhoneAndroid, contentDescription = null) }, + trailing = if (storage.mode == StorageMode.OWN) { + { SelectedCheck() } + } else { + null + }, + onClick = { select(StorageMode.OWN) }, + ) + GroupedRow( + title = externalTitle(external, storage.externalLabel), + summary = stringResource( + if (external == null) { + R.string.settings_store_external_missing + } else { + R.string.settings_store_external_hint + }, + ), + position = Position.Bottom, + selected = storage.mode == StorageMode.EXTERNAL, + dimmed = external == null, + leading = { Icon(Icons.Rounded.Apps, contentDescription = null) }, + trailing = if (storage.mode == StorageMode.EXTERNAL) { + { SelectedCheck() } + } else { + null + }, + onClick = if (external != null) ({ select(StorageMode.EXTERNAL) }) else null, + ) + Spacer(Modifier.height(24.dp)) + } +} + +/** The active store, named the way the picker names it. */ +@Composable +private fun storeLabel(storage: StorageUiState): String = when (storage.mode) { + StorageMode.OWN -> stringResource(R.string.settings_store_own) + StorageMode.EXTERNAL -> externalTitle(storage.external, storage.externalLabel) +} + +/** The provider's own app name, its authority, or the generic wording. */ +@Composable +private fun externalTitle(provider: TaskProvider?, label: String?): String = + label ?: provider?.authority ?: stringResource(R.string.settings_store_external) + +private fun Context.openAppSettings() { + runCatching { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri()), + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a6beca..dd0c5a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,6 +246,37 @@ Bottom quick-add bar Add tasks from a bar pinned to the bottom of a list, instead of the floating button + Use this device\'s storage instead + + + Storage + Where tasks are kept, and export + Task store + Each store keeps its own tasks. Switching does not move them across — export first if you want a copy. + On this device + Agendula\'s own storage. Nothing else to install. + Another task app + Share tasks with the app that syncs them + No compatible task app is installed + Permission denied + The other app\'s tasks stay unreachable until you allow access. Tap to open app settings. + Export tasks + Save your lists as iCalendar files + One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip. + No lists to export + Save to a folder + Save as a zip file + Exporting… + + Exported %1$d list + Exported %1$d lists + + The export could not be written + The chosen folder could not be opened + The chosen folder is not writable + A file could not be created in the chosen folder + Access to the chosen location was lost + %1$d minute before diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt index 9d7a988..c73eb3f 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -23,6 +23,7 @@ class ProviderResolverTest { ) : ProviderEnvironment { override fun packageDeclaring(authority: String): String? = installed[authority] override fun isGranted(permission: String): Boolean = permission in granted + override fun appLabel(packageName: String): String? = packageName } private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c216fb7..1d27981 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,7 +108,7 @@ postscript in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Package root | `data/prefs/` | `SettingsPrefs` (DataStore). | | `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). | | `data/demo/` | `DemoSeeder` (debug-only sample data). | -| `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. | +| `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/` (hub + sub-screens, `StorageScreen` among them), `export/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. | | root | `AgendulaApp` (Hilt app), `MainActivity`. | --- @@ -447,10 +447,21 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`, `RootScreen` is the entry composable: it gates on `ProviderStatus` (`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` → `AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is -only ever seen in External mode. Routes are the `Dest` table in -`ui/navigation/` (lists → task list → detail / edit, plus settings). Follow the -`material-3` skill for component choices (M3 `ListItem` rows, expressive -checkbox/FAB/swipe motion). +only ever seen in External mode — and it offers a way back to our own store, +because it is the only screen an External user can reach once their provider app +stops answering. Routes are the `Dest` table in `ui/navigation/` (lists → task +list → detail / edit, plus settings). Follow the `material-3` skill for component +choices (M3 `ListItem` rows, expressive checkbox/FAB/swipe motion). + +Settings is a hub of sliding sub-screens rather than routes; **Storage** is the +one with teeth. It holds the §4.1 store picker — which asks for an external +provider's runtime permission *before* writing the mode, so a denial leaves the +readable store in place instead of stranding the user on the gate — and the +export screen (`ui/export/`, one `.ics` per ticked list, written through SAF to a +folder or a single zip). Because the mode is now switchable while the process +lives, `AgendulaApp` re-arms reminders on `ProviderResolver.onModeChanged`: an +alarm is scheduled off whichever store was active at the time, so the whole set +has to be rebuilt against the new one. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a73b09f..945d92b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,10 +19,11 @@ iCalendar has landed, and the Material 3 Expressive UI is built through **M5**: lists → task list (swipe gestures, inline add, smart-list section headers) → detail / edit with full CRUD, date-time pickers, priority, percent-complete, conflict-safe saves, per-task reminders, and subtask create + reparent — plus a -one-time reminder onboarding step and a Settings screen. Remaining work is -hardening the new store (`OWN-STORE.md` phase 6), the **frontend surfaces for -what has landed** (a storage-mode picker, an export screen), then M6 (Glance -widget, translations, F-Droid release) and the sync adapter. +one-time reminder onboarding step and a Settings screen. The frontend surfaces +for the new store have landed too: a Settings **Storage** section with the +store picker and an **export screen**. Remaining work is verifying all of it on +a device, then M6 (Glance widget, translations, F-Droid release) and the sync +adapter. --- @@ -147,8 +148,17 @@ what Posture B means (our own store, coexisting with everything — *not* squatt - ✅ Export to iCalendar (step 3) — a v1 feature now that own-mode data lives only in our app's private storage. One `.ics` per list, to a folder or a zip, via SAF. Backend only. -- ⬜ **Frontend surfaces for the above** — a storage-mode picker in Settings and - an export screen. The backend is done and unused until these exist. +- ✅ **Frontend surfaces for the above** — Settings gained a **Storage** section + holding both: a full-screen store picker (Own / an installed external provider, + which is dimmed when none is present) that asks for the provider's runtime + permission *before* committing the switch, and an export screen with a per-list + tick and the two SAF destinations, a folder or a single zip. Two consequences + of the mode becoming switchable at runtime came with it: reminders are re-armed + against the new store on every switch (`AgendulaApp` listens on + `ProviderResolver.onModeChanged`; previously only a restart, a boot or an edit + resynced them), and the permission gate offers a way back to our own store — + otherwise a user whose provider app went away is held on a gate with Settings + behind it. - ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users. Note it now means "sync into an app that has no provider", so the ask has changed shape. @@ -264,9 +274,9 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through resolved: our own store expands a series at read time and writes an edit to one occurrence as a `RECURRENCE-ID` override sharing the master's UID; in External mode the edit still goes through the instances URI. -6. **Resolver ordering / mode-selection UX** — `autoMode()` picks a sane default - today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it - assumes is not built yet. +6. ~~**Resolver ordering / mode-selection UX**~~ resolved: `autoMode()` picks the + default (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1) and Settings → Storage + → Task store is the override it always assumed. 7. ~~**Sync protocol coverage**, account model, conflict resolution — the next design discussion.~~ Taken up in [`SYNC.md`](SYNC.md); the remaining opens live on that document's list. diff --git a/docs/STORAGE-AND-SYNC.md b/docs/STORAGE-AND-SYNC.md index 4cfb065..7224a45 100644 --- a/docs/STORAGE-AND-SYNC.md +++ b/docs/STORAGE-AND-SYNC.md @@ -42,7 +42,7 @@ |---|---|---|---| | 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | ✅ done | | 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done | -| 3 | Export / backup | our data now lives only in our app's private storage | ✅ backend done; no UI yet | +| 3 | Export / backup | our data now lives only in our app's private storage | ✅ done, UI included | | 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ | | 5 | Sync adapter | the 1.x arc; designed in [`SYNC.md`](SYNC.md), not yet built | ⬜ | @@ -202,8 +202,9 @@ than "rank ours first whenever it's non-empty", and needs no database probe: That permission is dangerous-level, so it can only be there because an earlier version asked and the user agreed — which is exactly what "existing Posture A -user" means. A fresh install holds nothing and gets local-first. ⬜ The Settings -override the rule assumes is not built yet. +user" means. A fresh install holds nothing and gets local-first. ✅ The Settings +override the rule assumes is built: Storage → Task store, which asks for the +external provider's permission before committing the switch rather than after. **Note on the mode vocabulary.** The code has two modes, not three: `StorageMode.LOCAL` and `StorageMode.EXTERNAL`. As this document says two @@ -211,7 +212,7 @@ paragraphs up, Synced *is* Local with an account attached — so it is derived state, and giving it its own constant would imply switching sync on is a migration when the whole point is that it isn't. -**Export/backup is a v1 feature.** ✅ Backend built (no UI yet). Not, as +**Export/backup is a v1 feature.** ✅ Built, screen included. Not, as previously framed, a migration safety net for "uninstall OpenTasks" — that scenario no longer exists. It's data portability for Local-mode users, whose tasks otherwise exist in exactly one place with no second copy. On Play, where