Add multi-photo bulk upload to the Android app

Pick or share several photos at once and post them in one batch, still
against the existing single-image /upload endpoint — no server change.

- Unify the post screen on a list model (PickedItem): a single-photo post
  or share is just a batch of one, so there is one code path.
- Multi-select via PickMultipleVisualMedia and multi-share via
  ACTION_SEND_MULTIPLE (manifest filter added).
- Per-photo caption and EXIF-prefilled date; each card shows its own
  upload status, with a summary and retry-only-failures on re-post.
- Enqueue one WorkManager worker per photo, reusing the durable retry
  queue. UploadQueue now appends a per-item suffix to the slug so a burst
  enqueued in the same second cannot collide and be dropped by
  enqueueUniqueWork.
This commit is contained in:
2026-08-27 08:43:24 +02:00
parent d4fec5b0ff
commit 6af0eb115c
5 changed files with 319 additions and 178 deletions
+1
View File
@@ -32,6 +32,7 @@
<!-- Share sheet target: pick a photo in Google Photos, share here. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
@@ -39,8 +39,8 @@ class MainActivity : ComponentActivity() {
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri -> uri?.let(viewModel::selectImage) }
ActivityResultContracts.PickMultipleVisualMedia(),
) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) }
val notificationPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
@@ -73,7 +73,7 @@ class MainActivity : ComponentActivity() {
} else {
PostScreen(
state = viewModel.post,
onPickImage = {
onPickImages = {
picker.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly,
@@ -82,6 +82,7 @@ class MainActivity : ComponentActivity() {
},
onCaptionChange = viewModel::setCaption,
onDateChange = viewModel::setDate,
onRemove = viewModel::removeItem,
onSubmit = viewModel::submit,
onReset = viewModel::clear,
onOpenSettings = { showSettings = true },
@@ -98,16 +99,31 @@ class MainActivity : ComponentActivity() {
consumeShareIntent(intent)
}
/** Picks up a photo handed over by the share sheet. */
/** Picks up one or several photos handed over by the share sheet. */
private fun consumeShareIntent(intent: Intent?) {
if (intent?.action != Intent.ACTION_SEND) return
val uri: Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
val uris: List<Uri> = when (intent?.action) {
Intent.ACTION_SEND -> {
val uri: Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
listOfNotNull(uri)
}
Intent.ACTION_SEND_MULTIPLE -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
}.orEmpty()
}
else -> return
}
uri?.let(viewModel::selectImage)
if (uris.isNotEmpty()) viewModel.selectImages(uris)
}
private fun openUrl(url: String) {
@@ -1,17 +1,20 @@
package ws.inflo.photogallery.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
@@ -36,8 +39,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -48,144 +49,214 @@ private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM
@Composable
fun PostScreen(
state: PostUiState,
onPickImage: () -> Unit,
onCaptionChange: (String) -> Unit,
onDateChange: (LocalDate) -> Unit,
onPickImages: () -> Unit,
onCaptionChange: (id: String, value: String) -> Unit,
onDateChange: (id: String, value: LocalDate) -> Unit,
onRemove: (id: String) -> Unit,
onSubmit: () -> Unit,
onReset: () -> Unit,
onOpenSettings: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
var datePickerOpen by remember { mutableStateOf(false) }
// Which item's date is being edited, if any.
var dateEditFor by remember { mutableStateOf<PickedItem?>(null) }
val pendingCount = state.items.count { it.status !is ItemStatus.Success }
val canPost = state.hasItems && !state.posting && pendingCount > 0
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Post a photo", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
TextButton(onClick = onOpenSettings) { Text("Settings") }
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
TextButton(onClick = onOpenSettings) { Text("Settings") }
}
Spacer(Modifier.height(12.dp))
}
Spacer(Modifier.height(12.dp))
item {
OutlinedButton(onClick = onPickImages, modifier = Modifier.fillMaxWidth()) {
Text(if (state.hasItems) "Choose different photos" else "Choose photos")
}
Spacer(Modifier.height(16.dp))
}
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(GruvBg1),
contentAlignment = Alignment.Center,
) {
val preview = state.preview
when {
preview != null -> Image(
bitmap = preview.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
state.imageUri != null -> CircularProgressIndicator(color = GruvAqua)
else -> Text("No photo selected", color = GruvFg4)
if (!state.hasItems) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.background(GruvBg1),
contentAlignment = Alignment.Center,
) {
Text("No photos selected", color = GruvFg4)
}
}
}
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onPickImage, modifier = Modifier.fillMaxWidth()) {
Text(if (state.imageUri == null) "Choose photo" else "Choose a different photo")
items(state.items, key = { it.id }) { item ->
ItemCard(
item = item,
onCaptionChange = { onCaptionChange(item.id, it) },
onEditDate = { dateEditFor = item },
onRemove = { onRemove(item.id) },
onOpenUrl = onOpenUrl,
)
Spacer(Modifier.height(12.dp))
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.caption,
onValueChange = onCaptionChange,
label = { Text("Caption") },
placeholder = { Text("Defaults to the filename if left blank") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = { datePickerOpen = true },
modifier = Modifier.fillMaxWidth(),
) {
Text("Date: ${state.date.format(DISPLAY_DATE)}")
}
Spacer(Modifier.height(20.dp))
Button(
onClick = onSubmit,
enabled = state.imageUri != null && state.status !is PostStatus.Uploading,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.status is PostStatus.Uploading) "Posting…" else "Post")
}
Spacer(Modifier.height(16.dp))
when (val status = state.status) {
is PostStatus.Error -> Text(status.message, color = GruvRed)
is PostStatus.Success -> Column {
Text("Posted.", color = GruvAqua)
status.note?.let {
Spacer(Modifier.height(4.dp))
Text("Note: $it", color = GruvFg4, style = MaterialTheme.typography.bodySmall)
}
if (state.hasItems) {
item {
Spacer(Modifier.height(8.dp))
Row {
TextButton(onClick = { onOpenUrl(status.url) }) { Text("Open") }
TextButton(onClick = onReset) { Text("Post another") }
Button(
onClick = onSubmit,
enabled = canPost,
modifier = Modifier.fillMaxWidth(),
) {
Text(
when {
state.posting -> "Posting… (${state.succeeded}/${state.items.size})"
pendingCount == 0 -> "All posted"
pendingCount == 1 -> "Post 1 photo"
else -> "Post $pendingCount photos"
},
)
}
if (state.allDone) {
Spacer(Modifier.height(12.dp))
val summary = buildString {
append("${state.succeeded} posted")
if (state.failed > 0) append(", ${state.failed} failed")
}
Text(summary, color = if (state.failed > 0) GruvRed else GruvAqua)
Spacer(Modifier.height(8.dp))
TextButton(onClick = onReset) { Text("Clear") }
}
}
else -> Unit
}
state.lastPost?.let { last ->
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = GruvBg2)
Spacer(Modifier.height(12.dp))
Text("Last post", color = GruvFg4, style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
Text(
last.caption.ifBlank { last.slug },
color = GruvFg,
style = MaterialTheme.typography.bodyMedium,
)
Text(
last.slug,
color = GruvFg4,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = { onOpenUrl(last.url) }) { Text("Open") }
item {
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = GruvBg2)
Spacer(Modifier.height(12.dp))
Text("Last post", color = GruvFg4, style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
Text(
last.caption.ifBlank { last.slug },
color = GruvFg,
style = MaterialTheme.typography.bodyMedium,
)
Text(
last.slug,
color = GruvFg4,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
)
TextButton(onClick = { onOpenUrl(last.url) }) { Text("Open") }
}
}
}
}
if (datePickerOpen) {
dateEditFor?.let { editing ->
DatePickerModal(
initial = state.date,
onDismiss = { datePickerOpen = false },
initial = editing.date,
onDismiss = { dateEditFor = null },
onConfirm = {
onDateChange(it)
datePickerOpen = false
onDateChange(editing.id, it)
dateEditFor = null
},
)
}
}
@Composable
private fun ItemCard(
item: PickedItem,
onCaptionChange: (String) -> Unit,
onEditDate: () -> Unit,
onRemove: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
Row(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.size(84.dp)
.background(GruvBg1),
contentAlignment = Alignment.Center,
) {
val preview = item.preview
when {
preview != null -> Image(
bitmap = preview.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
else -> CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(24.dp))
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
OutlinedTextField(
value = item.caption,
onValueChange = onCaptionChange,
label = { Text("Caption") },
singleLine = true,
enabled = item.status !is ItemStatus.Uploading,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = onEditDate,
enabled = item.status !is ItemStatus.Uploading,
) {
Text(item.date.format(DISPLAY_DATE))
}
Spacer(Modifier.width(8.dp))
ItemStatusLabel(item.status, onOpenUrl)
}
}
Spacer(Modifier.width(8.dp))
when (item.status) {
is ItemStatus.Uploading ->
CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(20.dp))
is ItemStatus.Success -> Unit
else -> TextButton(onClick = onRemove) { Text("✕", color = GruvFg4) }
}
}
}
@Composable
private fun ItemStatusLabel(status: ItemStatus, onOpenUrl: (String) -> Unit) {
when (status) {
is ItemStatus.Pending -> Unit
is ItemStatus.Uploading -> Text("Uploading…", color = GruvFg4, style = MaterialTheme.typography.bodySmall)
is ItemStatus.Success -> TextButton(onClick = { onOpenUrl(status.url) }) { Text("View") }
is ItemStatus.Error -> Text(
status.message,
color = GruvRed,
style = MaterialTheme.typography.bodySmall,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DatePickerModal(
@@ -24,22 +24,41 @@ import java.io.File
import java.time.LocalDate
import java.util.UUID
sealed interface PostStatus {
data object Idle : PostStatus
data object Uploading : PostStatus
data class Success(val url: String, val note: String?) : PostStatus
data class Error(val message: String) : PostStatus
/** Per-photo status within a batch. */
sealed interface ItemStatus {
data object Pending : ItemStatus
data object Uploading : ItemStatus
data class Success(val url: String, val note: String?) : ItemStatus
data class Error(val message: String) : ItemStatus
}
data class PostUiState(
val imageUri: Uri? = null,
/**
* One photo in the batch. A single-photo post (picker or share) is just a
* batch of one, so the whole screen runs a single code path.
*/
data class PickedItem(
val id: String = UUID.randomUUID().toString(),
val source: Uri,
val preview: Bitmap? = null,
val caption: String = "",
val date: LocalDate = LocalDate.now(),
val status: PostStatus = PostStatus.Idle,
val lastPost: LastPost? = null,
val status: ItemStatus = ItemStatus.Pending,
)
data class PostUiState(
val items: List<PickedItem> = emptyList(),
val lastPost: LastPost? = null,
) {
val posting: Boolean get() = items.any { it.status is ItemStatus.Uploading }
val hasItems: Boolean get() = items.isNotEmpty()
val succeeded: Int get() = items.count { it.status is ItemStatus.Success }
val failed: Int get() = items.count { it.status is ItemStatus.Error }
/** True once every item has reached a terminal state and at least one ran. */
val allDone: Boolean
get() = items.isNotEmpty() &&
items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error }
}
data class SettingsUiState(
val baseUrl: String = "",
val username: String = "",
@@ -69,57 +88,81 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
// ── Post screen ─────────────────────────────────────────────────────────
fun selectImage(uri: Uri) {
post = post.copy(imageUri = uri, status = PostStatus.Idle, preview = null)
viewModelScope.launch {
val context = getApplication<Application>()
val preview = withContext(Dispatchers.IO) { ImagePrep.preview(context, uri) }
val captured = withContext(Dispatchers.IO) { ImagePrep.captureDate(context, uri) }
post = post.copy(preview = preview, date = captured ?: LocalDate.now())
/** Replaces the current selection with [uris] and loads each preview. */
fun selectImages(uris: List<Uri>) {
if (uris.isEmpty()) return
val items = uris.map { PickedItem(source = it) }
post = post.copy(items = items)
val context = getApplication<Application>()
for (item in items) {
viewModelScope.launch {
val preview = withContext(Dispatchers.IO) {
ImagePrep.preview(context, item.source, maxDimension = 256)
}
val captured = withContext(Dispatchers.IO) {
ImagePrep.captureDate(context, item.source)
}
updateItem(item.id) {
it.copy(preview = preview, date = captured ?: it.date)
}
}
}
}
fun setCaption(value: String) {
post = post.copy(caption = value)
fun setCaption(id: String, value: String) {
updateItem(id) { it.copy(caption = value) }
}
fun setDate(value: LocalDate) {
post = post.copy(date = value)
fun setDate(id: String, value: LocalDate) {
updateItem(id) { it.copy(date = value) }
}
fun removeItem(id: String) {
// A photo already uploading keeps its worker; just drop it from the UI.
post = post.copy(items = post.items.filterNot { it.id == id })
}
fun clear() {
post = PostUiState(lastPost = settings.lastPost)
}
/** Uploads every item not already posted. Re-running retries only failures. */
fun submit() {
val uri = post.imageUri ?: return
if (!settings.isConfigured) {
post = post.copy(status = PostStatus.Error("Set the server URL and credentials first"))
post = post.copy(
items = post.items.map {
if (it.status is ItemStatus.Success) it
else it.copy(status = ItemStatus.Error("Set the server URL and credentials first"))
},
)
return
}
post = post.copy(status = PostStatus.Uploading)
val pending = post.items.filter { it.status !is ItemStatus.Success }
if (pending.isEmpty()) return
viewModelScope.launch {
val context = getApplication<Application>()
// Stage the bytes into our own cache before handing the job to
// WorkManager. A content:// URI from the share sheet is only
// readable while the grant lasts, and a retry can fire long after
// the activity that received it is gone.
val staged = withContext(Dispatchers.IO) { stage(uri) }
if (staged == null) {
post = post.copy(status = PostStatus.Error("Could not read the selected image"))
return@launch
val context = getApplication<Application>()
for (item in pending) {
updateItem(item.id) { it.copy(status = ItemStatus.Uploading) }
viewModelScope.launch {
val staged = withContext(Dispatchers.IO) { stage(item.source) }
if (staged == null) {
updateItem(item.id) {
it.copy(status = ItemStatus.Error("Could not read the selected image"))
}
return@launch
}
val current = post.items.firstOrNull { it.id == item.id } ?: return@launch
val workId = UploadQueue.enqueue(
context = context,
uri = Uri.fromFile(staged),
caption = current.caption,
date = current.date,
uniqueSuffix = item.id.take(6),
)
observe(item.id, workId)
}
val id = UploadQueue.enqueue(
context = context,
uri = Uri.fromFile(staged),
caption = post.caption,
date = post.date,
)
observe(id)
}
}
@@ -136,32 +179,35 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
}
}
private fun observe(id: UUID) {
private fun observe(itemId: String, workId: UUID) {
viewModelScope.launch {
WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(id).collect { info ->
WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(workId).collect { info ->
if (info == null) return@collect
when (info.state) {
WorkInfo.State.SUCCEEDED -> {
post = post.copy(
status = PostStatus.Success(
url = info.outputData.getString(UploadWorker.KEY_RESULT_URL).orEmpty(),
note = info.outputData.getString(UploadWorker.KEY_RESULT_NOTE),
),
lastPost = settings.lastPost,
)
updateItem(itemId) {
it.copy(
status = ItemStatus.Success(
url = info.outputData.getString(UploadWorker.KEY_RESULT_URL).orEmpty(),
note = info.outputData.getString(UploadWorker.KEY_RESULT_NOTE),
),
)
}
post = post.copy(lastPost = settings.lastPost)
}
WorkInfo.State.FAILED -> {
post = post.copy(
status = PostStatus.Error(
WorkInfo.State.FAILED -> updateItem(itemId) {
it.copy(
status = ItemStatus.Error(
info.outputData.getString(UploadWorker.KEY_RESULT_ERROR)
?: "Upload failed",
),
)
}
WorkInfo.State.CANCELLED ->
post = post.copy(status = PostStatus.Error("Upload cancelled"))
WorkInfo.State.CANCELLED -> updateItem(itemId) {
it.copy(status = ItemStatus.Error("Upload cancelled"))
}
else -> Unit
}
@@ -169,6 +215,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
}
}
private inline fun updateItem(id: String, transform: (PickedItem) -> PickedItem) {
post = post.copy(items = post.items.map { if (it.id == id) transform(it) else it })
}
// ── Settings screen ─────────────────────────────────────────────────────
fun setBaseUrl(value: String) {
@@ -18,9 +18,11 @@ object UploadQueue {
/**
* Slug format. Uses the enqueue wall-clock time rather than the photo's
* capture time: a burst of frames shot in the same second would collide on
* capture time, whereas posts are made one at a time. The real capture date
* still travels in the `date` field and lands in the .toml sidecar.
* capture time: capture time collides across a burst. A [uniqueSuffix] is
* appended so that a bulk post — several photos enqueued in the same second
* — cannot collide either; without it enqueueUniqueWork would silently drop
* the duplicates. The real capture date still travels in the `date` field
* and lands in the .toml sidecar.
*/
private val SLUG_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")
@@ -32,8 +34,9 @@ object UploadQueue {
uri: Uri,
caption: String,
date: LocalDate,
uniqueSuffix: String,
): UUID {
val slug = LocalDateTime.now().format(SLUG_FORMAT)
val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(