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