feat(settings): storage picker and export screen

The store picker and the export screen were the two frontend surfaces
the own-store work left unbuilt, so both backends shipped unreachable.
Settings gains a Storage section holding them: a full-screen picker over
Own / an installed external provider (dimmed when none is present, named
after the provider's own app), and an export screen with a per-list tick
and the two SAF destinations, a folder or a single zip. The picker asks
for the provider's runtime permission before writing the mode, so a
denial leaves the readable store in place instead of dropping the user on
the gate; a refusal is reported with a route to app settings.

Making the mode switchable at runtime had two consequences:

- reminders are armed off whichever store was active when they were
  scheduled, so a switch rebuilds the set. ReminderScheduler.sync() is
  now serialised — it is a read-modify-write over ScheduledReminderStore,
  and overlapping runs each wrote their own set as the whole truth
- the permission gate is the only screen an External user can reach once
  their provider app stops answering, so it offers the way back to our
  own store

ExportWriter no longer deletes a previous export before recreating it (a
failure in between lost both), lists the target directory once instead of
per document, and carries a typed ExportFailure so the screen can report
in the user's language rather than an exception message.
This commit is contained in:
2026-09-04 15:37:48 +02:00
parent 9ff6027e50
commit ec2e2eb59d
17 changed files with 845 additions and 79 deletions

View File

@@ -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
}
}

View File

@@ -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<String>)
/** 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<ExportDocument>): 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
}

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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) }
}
}

View File

@@ -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) }
}
}

View File

@@ -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),
)
}

View File

@@ -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<TaskList> = emptyList(),
/** Lists the user has ticked *off*; everything else is included. */
val excluded: Set<Long> = 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<Long>())
private val running = MutableStateFlow(false)
private val outcome = MutableStateFlow<ExportOutcome?>(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<ExportUiState> =
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<ExportDocument>) -> 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
}
}
}
}

View File

@@ -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<PermissionUiState> = _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<Unit> = callbackFlow {
trySend(Unit)
val handle = providerResolver.onModeChanged { trySend(Unit) }
awaitClose { handle.close() }
}.buffer(Channel.CONFLATED)
val state: StateFlow<PermissionUiState> =
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

View File

@@ -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
}

View File

@@ -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<TaskList> = 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<StorageUiState?> =
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) }

View File

@@ -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()),
)
}
}

View File

@@ -246,6 +246,37 @@
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
<string name="onboarding_use_own_store">Use this device\'s storage instead</string>
<!-- Storage and export -->
<string name="settings_section_storage">Storage</string>
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
<string name="settings_task_store">Task store</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — export first if you want a copy.</string>
<string name="settings_store_own">On this device</string>
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
<string name="settings_store_external">Another task app</string>
<string name="settings_store_external_hint">Share tasks with the app that syncs them</string>
<string name="settings_store_external_missing">No compatible task app is installed</string>
<string name="settings_store_permission_denied">Permission denied</string>
<string name="settings_store_permission_denied_hint">The other app\'s tasks stay unreachable until you allow access. Tap to open app settings.</string>
<string name="settings_export">Export tasks</string>
<string name="settings_export_hint">Save your lists as iCalendar files</string>
<string name="export_hint">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.</string>
<string name="export_no_lists">No lists to export</string>
<string name="export_to_folder">Save to a folder</string>
<string name="export_to_zip">Save as a zip file</string>
<string name="export_running">Exporting…</string>
<plurals name="export_done">
<item quantity="one">Exported %1$d list</item>
<item quantity="other">Exported %1$d lists</item>
</plurals>
<string name="export_failed">The export could not be written</string>
<string name="export_failed_folder">The chosen folder could not be opened</string>
<string name="export_failed_read_only">The chosen folder is not writable</string>
<string name="export_failed_create">A file could not be created in the chosen folder</string>
<string name="export_failed_access">Access to the chosen location was lost</string>
<!-- Reminder lead times (custom amounts) -->
<plurals name="reminder_minutes">
<item quantity="one">%1$d minute before</item>

View File

@@ -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" }