Files
photog/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt
T
puttaalu 6af0eb115c 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.
2026-08-27 08:43:24 +02:00

138 lines
5.4 KiB
Kotlin

package ws.inflo.photogallery
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import ws.inflo.photogallery.ui.PhotoGalleryTheme
import ws.inflo.photogallery.ui.PostScreen
import ws.inflo.photogallery.ui.SettingsScreen
import ws.inflo.photogallery.ui.UploadViewModel
class MainActivity : ComponentActivity() {
private val viewModel: UploadViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
consumeShareIntent(intent)
setContent {
PhotoGalleryTheme {
// Land on settings until there is somewhere to post to.
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(),
) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) }
val notificationPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* Upload works either way; this only affects the progress notification. */ }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
this@MainActivity,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
if (showSettings) {
SettingsScreen(
state = viewModel.config,
onBaseUrlChange = viewModel::setBaseUrl,
onUsernameChange = viewModel::setUsername,
onPasswordChange = viewModel::setPassword,
onSave = viewModel::saveAndTest,
onBack = if (viewModel.isConfigured) {
{ showSettings = false }
} else {
null
},
)
} else {
PostScreen(
state = viewModel.post,
onPickImages = {
picker.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly,
),
)
},
onCaptionChange = viewModel::setCaption,
onDateChange = viewModel::setDate,
onRemove = viewModel::removeItem,
onSubmit = viewModel::submit,
onReset = viewModel::clear,
onOpenSettings = { showSettings = true },
onOpenUrl = ::openUrl,
)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
consumeShareIntent(intent)
}
/** Picks up one or several photos handed over by the share sheet. */
private fun consumeShareIntent(intent: Intent?) {
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
}
if (uris.isNotEmpty()) viewModel.selectImages(uris)
}
private fun openUrl(url: String) {
if (url.isBlank()) return
try {
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
} catch (_: Exception) {
Toast.makeText(this, "No browser to open $url", Toast.LENGTH_SHORT).show()
}
}
}