Add an edit/delete manage screen to the Android app

A new Manage screen (reached from the Post screen) lists published posts with
thumbnails and lets you edit a post's caption, date and tags or delete it to the
server's trash, over the same /api/photos, /api/edit and /api/delete endpoints
the web /manage uses.

UploadClient gains listPhotos/editPost/deletePost/fetchThumbnail, parsing JSON
with the built-in org.json (no new dependency) and posting edits/deletes as
urlencoded forms. The app sends no Origin or Sec-Fetch-Site header, so it passes
the server's same-origin CSRF check as a non-browser client; Basic auth is
already attached on every request. UploadViewModel holds the manage state,
loads the list, fetches thumbnails in the background, and applies edits/deletes
optimistically.
This commit is contained in:
puttaalu committed 2026-10-18 23:23:39 +02:00
1 parent e67d25b191
commit 225bdc9b4f
7 files changed
+584 -10

No files matched your search

+2 -2
View File
@@ -189,7 +189,7 @@ go run . --serve
## Android app
Kotlin + Jetpack Compose uploader: pick or share a photo, add caption and date, post. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail.
Kotlin + Jetpack Compose uploader: pick or share one or many photos, add caption/date/tags, post via a durable WorkManager queue. It also has a **Manage screen** (from the Post screen's "Manage" button) that lists published posts with thumbnails and edits caption/date/tags or deletes (to trash) via the same `/api/photos`, `/api/edit`, `/api/delete` endpoints the web `/manage` uses. The manage calls go through `UploadClient` (OkHttp + `org.json`, no extra deps); sending no `Origin`/`Sec-Fetch-Site` header, the app passes the server's same-origin CSRF check as a non-browser client. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail.
Its own flake, deliberately separate from the root one so NixOS consumers of `nixosModules.default` don't pull the Android SDK into their lock:
@@ -262,7 +262,7 @@ nix build # succeeds
## What's not done yet
- ActivityPub federation (planned later — start with RSS/Atom)
- Edit/delete/trash in the Android and Qt apps (web `/manage` only so far; the `/api/` endpoints are there for the apps to adopt)
- Edit/delete in the **Qt desktop** app (web `/manage` and the Android app have it; the `/api/` endpoints are there for Qt to adopt). Restoring from trash is still web-only on all clients.
- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
- The Android app has never been run on real hardware
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
+7 -4
View File
@@ -1,11 +1,14 @@
# Gallery Upload — Android client
Native uploader for the photogallery `POST /upload` endpoint. Pick or share a
photo, add a caption and date, post. Credentials are entered once and sealed
with a key held in the Android Keystore.
Native uploader and manager for the photogallery server. Pick or share one or
many photos, add caption/date/tags, and post. A **Manage** screen lists the
published posts and edits their caption/date/tags or deletes them (to the
server's trash) over the same `/api/*` endpoints the web `/manage` uses.
Credentials are entered once and sealed with a key held in the Android Keystore.
The server is not modified by this app. Everything it needs, it does client
side.
side; the management calls send no browser headers, so they pass the server's
same-origin (CSRF) check as a non-browser client.
## Build
@@ -20,6 +20,7 @@ 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.ManageScreen
import ws.inflo.photogallery.ui.PhotoGalleryTheme
import ws.inflo.photogallery.ui.PostScreen
import ws.inflo.photogallery.ui.SettingsScreen
@@ -37,6 +38,7 @@ class MainActivity : ComponentActivity() {
PhotoGalleryTheme {
// Land on settings until there is somewhere to post to.
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
var showManage by rememberSaveable { mutableStateOf(false) }
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(),
@@ -57,8 +59,8 @@ class MainActivity : ComponentActivity() {
}
}
if (showSettings) {
SettingsScreen(
when {
showSettings -> SettingsScreen(
state = viewModel.config,
onBaseUrlChange = viewModel::setBaseUrl,
onUsernameChange = viewModel::setUsername,
@@ -70,8 +72,17 @@ class MainActivity : ComponentActivity() {
null
},
)
} else {
PostScreen(
showManage -> ManageScreen(
state = viewModel.manage,
onBack = { showManage = false },
onRefresh = viewModel::loadManage,
onSave = viewModel::saveEdit,
onDelete = viewModel::deletePost,
onDismissNotice = viewModel::clearManageNotice,
)
else -> PostScreen(
state = viewModel.post,
onPickImages = {
picker.launch(
@@ -87,6 +98,10 @@ class MainActivity : ComponentActivity() {
onSubmit = viewModel::submit,
onReset = viewModel::clear,
onOpenSettings = { showSettings = true },
onOpenManage = {
viewModel.loadManage()
showManage = true
},
onOpenUrl = ::openUrl,
)
}
@@ -1,6 +1,9 @@
package ws.inflo.photogallery.net
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import okhttp3.Credentials
import okhttp3.FormBody
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
@@ -8,6 +11,8 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import org.json.JSONArray
import org.json.JSONException
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
@@ -25,6 +30,22 @@ sealed interface UploadOutcome {
data class Transient(val message: String) : UploadOutcome
}
/** One published post, as returned by GET /api/photos. */
data class RemotePhoto(
val slug: String,
val caption: String,
val date: String, // YYYY-MM-DD
val tags: List<String>,
val thumb: String, // site-relative, e.g. "/thumbs/foo.jpg"
val url: String, // site-relative, e.g. "/photo/foo/"
)
/** Result of listing posts for the manage screen. */
sealed interface ListResult {
data class Ok(val photos: List<RemotePhoto>) : ListResult
data class Err(val message: String) : ListResult
}
/**
* Talks to the gallery's POST /upload endpoint.
*
@@ -122,6 +143,123 @@ class UploadClient(
}
}
/**
* Lists every published post for the manage screen. The manage endpoints
* sit behind the same Basic auth as /upload; the app sends no Origin or
* Sec-Fetch-Site header, so it passes the server's same-origin (CSRF) check
* as a non-browser client.
*/
fun listPhotos(): ListResult {
val request = Request.Builder().url("$base/api/photos").get().build()
return try {
client.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
when {
response.isSuccessful -> parsePhotos(text)
response.code == 401 || response.code == 403 ->
ListResult.Err("Wrong username or password")
else -> ListResult.Err("Server returned ${response.code}")
}
}
} catch (e: IOException) {
ListResult.Err(e.message ?: "Network error")
} catch (e: JSONException) {
ListResult.Err("Could not read the server's response")
}
}
private fun parsePhotos(body: String): ListResult {
val arr = JSONArray(body)
val out = ArrayList<RemotePhoto>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val tagsArr = o.optJSONArray("tags")
val tags = ArrayList<String>()
if (tagsArr != null) {
for (j in 0 until tagsArr.length()) tags.add(tagsArr.getString(j))
}
out.add(
RemotePhoto(
slug = o.getString("slug"),
caption = o.optString("caption"),
date = o.optString("date"),
tags = tags,
thumb = o.optString("thumb"),
url = o.optString("url"),
),
)
}
return ListResult.Ok(out)
}
/**
* Rewrites a post's sidecar. The slug (and its URL) never changes. Returns
* null on success, or a human-readable error.
*/
fun editPost(slug: String, caption: String, date: String, tags: String): String? {
val form = FormBody.Builder()
.add("slug", slug)
.add("caption", caption)
.add("date", date)
.add("tags", tags)
.build()
val request = Request.Builder().url("$base/api/edit").post(form).build()
return simpleResult(request)
}
/**
* Moves a post to the server's trash (reversible there). Returns null on
* success, or a human-readable error — including the case where the server
* reports that nothing was deleted.
*/
fun deletePost(slug: String): String? {
val form = FormBody.Builder().add("slug", slug).build()
val request = Request.Builder().url("$base/api/delete").post(form).build()
return try {
client.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
when {
response.isSuccessful -> {
val deleted = runCatching {
org.json.JSONObject(text).optInt("deleted", 0)
}.getOrDefault(0)
if (deleted > 0) null else "Nothing deleted — not found on the server"
}
response.code == 401 || response.code == 403 -> "Wrong username or password"
else -> text.trim().ifEmpty { "Server returned ${response.code}" }
}
}
} catch (e: IOException) {
e.message ?: "Network error"
}
}
/** Fetches a thumbnail by its site-relative [path], or null on any failure. */
fun fetchThumbnail(path: String): Bitmap? {
val request = Request.Builder().url("$base$path").get().build()
return try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
response.body?.byteStream()?.let { BitmapFactory.decodeStream(it) }
}
} catch (e: IOException) {
null
}
}
private fun simpleResult(request: Request): String? = try {
client.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
when {
response.isSuccessful -> null
response.code == 401 || response.code == 403 -> "Wrong username or password"
else -> text.trim().ifEmpty { "Server returned ${response.code}" }
}
}
} catch (e: IOException) {
e.message ?: "Network error"
}
/** Server replies "uploaded <slug>.<ext>"; recover the slug from that. */
private fun parseSlug(responseBody: String, fallback: String): String {
val name = responseBody.trim().removePrefix("uploaded ").trim()
@@ -0,0 +1,294 @@
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.fillMaxSize
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.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
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.Alignment
import androidx.compose.ui.Modifier
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 java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM yyyy")
@Composable
fun ManageScreen(
state: ManageUiState,
onBack: () -> Unit,
onRefresh: () -> Unit,
onSave: (slug: String, caption: String, date: LocalDate, tags: String) -> Unit,
onDelete: (slug: String) -> Unit,
onDismissNotice: () -> Unit,
) {
var editing by remember { mutableStateOf<ManagePost?>(null) }
var deleting by remember { mutableStateOf<ManagePost?>(null) }
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Manage posts", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
Row {
TextButton(onClick = onRefresh) { Text("Refresh") }
TextButton(onClick = onBack) { Text("Back") }
}
}
Spacer(Modifier.height(8.dp))
}
state.error?.let { msg ->
item { Notice(msg, GruvRed, onDismissNotice) }
}
state.message?.let { msg ->
item { Notice(msg, GruvAqua, onDismissNotice) }
}
if (state.loading) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator(color = GruvAqua) }
}
} else if (state.items.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center,
) { Text("No posts.", color = GruvFg4) }
}
}
items(state.items, key = { it.slug }) { post ->
PostRow(
post = post,
onEdit = { editing = post },
onDelete = { deleting = post },
)
Spacer(Modifier.height(12.dp))
}
}
}
editing?.let { post ->
EditDialog(
post = post,
onDismiss = { editing = null },
onSave = { caption, date, tags ->
onSave(post.slug, caption, date, tags)
editing = null
},
)
}
deleting?.let { post ->
AlertDialog(
onDismissRequest = { deleting = null },
title = { Text("Move to trash?") },
text = { Text("\"${post.caption.ifBlank { post.slug }}\" will be moved to the server's trash.") },
confirmButton = {
TextButton(onClick = {
onDelete(post.slug)
deleting = null
}) { Text("Delete", color = GruvRed) }
},
dismissButton = { TextButton(onClick = { deleting = null }) { Text("Cancel") } },
)
}
}
@Composable
private fun Notice(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(GruvBg1)
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(message, color = color, style = MaterialTheme.typography.bodyMedium)
TextButton(onClick = onDismiss) { Text("Dismiss") }
}
Spacer(Modifier.height(12.dp))
}
@Composable
private fun PostRow(post: ManagePost, onEdit: () -> Unit, onDelete: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.size(72.dp)
.background(GruvBg1),
contentAlignment = Alignment.Center,
) {
val preview = post.preview
if (preview != null) {
Image(
bitmap = preview.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
post.caption.ifBlank { post.slug },
color = GruvFg,
style = MaterialTheme.typography.bodyLarge,
)
Text(post.date.format(DISPLAY_DATE), color = GruvFg4, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
if (post.tags.isNotEmpty()) {
Text(post.tags.joinToString(" · "), color = GruvAqua, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(4.dp))
Row {
if (post.busy) {
CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(18.dp))
} else {
OutlinedButton(onClick = onEdit) { Text("Edit") }
Spacer(Modifier.width(8.dp))
TextButton(onClick = onDelete) { Text("Delete", color = GruvRed) }
}
}
}
}
}
@Composable
private fun EditDialog(
post: ManagePost,
onDismiss: () -> Unit,
onSave: (caption: String, date: LocalDate, tags: String) -> Unit,
) {
var caption by remember { mutableStateOf(post.caption) }
var tags by remember { mutableStateOf(post.tags.joinToString(", ")) }
var date by remember { mutableStateOf(post.date) }
var pickingDate by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit ${post.slug}") },
text = {
Column {
OutlinedTextField(
value = caption,
onValueChange = { caption = it },
label = { Text("Caption") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = tags,
onValueChange = { tags = it },
label = { Text("Tags") },
placeholder = { Text("comma-separated") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { pickingDate = true }) {
Text(date.format(DISPLAY_DATE))
}
}
},
confirmButton = {
TextButton(onClick = { onSave(caption, date, tags) }) { Text("Save") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
if (pickingDate) {
DatePickerModal(
initial = date,
onDismiss = { pickingDate = false },
onConfirm = {
date = it
pickingDate = false
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DatePickerModal(
initial: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate) -> Unit,
) {
val pickerState = rememberDatePickerState(
initialSelectedDateMillis = initial
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
val millis = pickerState.selectedDateMillis
if (millis != null) {
onConfirm(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate())
} else {
onDismiss()
}
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
) {
DatePicker(state = pickerState)
}
}
@@ -57,6 +57,7 @@ fun PostScreen(
onSubmit: () -> Unit,
onReset: () -> Unit,
onOpenSettings: () -> Unit,
onOpenManage: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
// Which item's date is being edited, if any.
@@ -78,8 +79,11 @@ fun PostScreen(
verticalAlignment = Alignment.CenterVertically,
) {
Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
Row {
TextButton(onClick = onOpenManage) { Text("Manage") }
TextButton(onClick = onOpenSettings) { Text("Settings") }
}
}
Spacer(Modifier.height(12.dp))
}
@@ -17,11 +17,14 @@ import kotlinx.coroutines.withContext
import ws.inflo.photogallery.data.LastPost
import ws.inflo.photogallery.data.SettingsStore
import ws.inflo.photogallery.image.ImagePrep
import ws.inflo.photogallery.net.ListResult
import ws.inflo.photogallery.net.RemotePhoto
import ws.inflo.photogallery.net.UploadClient
import ws.inflo.photogallery.work.UploadQueue
import ws.inflo.photogallery.work.UploadWorker
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.UUID
/** Per-photo status within a batch. */
@@ -60,6 +63,25 @@ data class PostUiState(
items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error }
}
/** One published post on the manage screen. */
data class ManagePost(
val slug: String,
val caption: String,
val date: LocalDate,
val tags: List<String>,
val thumb: String, // site-relative path
val url: String, // site-relative path
val preview: Bitmap? = null,
val busy: Boolean = false, // a save/delete is in flight
)
data class ManageUiState(
val loading: Boolean = false,
val items: List<ManagePost> = emptyList(),
val error: String? = null,
val message: String? = null,
)
data class SettingsUiState(
val baseUrl: String = "",
val username: String = "",
@@ -225,6 +247,104 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
post = post.copy(items = post.items.map { if (it.id == id) transform(it) else it })
}
// ── Manage screen ───────────────────────────────────────────────────────
var manage by mutableStateOf(ManageUiState())
private set
private val isoDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
private fun client(): UploadClient =
UploadClient(settings.baseUrl, settings.username, settings.password)
/** Loads the post list, then fills in thumbnails in the background. */
fun loadManage() {
if (!settings.isConfigured) {
manage = manage.copy(error = "Set the server URL and credentials first")
return
}
manage = manage.copy(loading = true, error = null, message = null)
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { client().listPhotos() }
when (result) {
is ListResult.Ok -> {
manage = manage.copy(
loading = false,
items = result.photos.map { it.toPost() },
)
loadThumbnails(result.photos)
}
is ListResult.Err ->
manage = manage.copy(loading = false, error = result.message)
}
}
}
private fun loadThumbnails(photos: List<RemotePhoto>) {
for (p in photos) {
viewModelScope.launch {
val bmp = withContext(Dispatchers.IO) { client().fetchThumbnail(p.thumb) }
if (bmp != null) updateManage(p.slug) { it.copy(preview = bmp) }
}
}
}
fun saveEdit(slug: String, caption: String, date: LocalDate, tags: String) {
updateManage(slug) { it.copy(busy = true) }
viewModelScope.launch {
val err = withContext(Dispatchers.IO) {
client().editPost(slug, caption, date.format(isoDate), tags)
}
if (err == null) {
updateManage(slug) {
it.copy(
busy = false,
caption = caption,
date = date,
tags = parseTagList(tags),
)
}
manage = manage.copy(message = "Saved")
} else {
updateManage(slug) { it.copy(busy = false) }
manage = manage.copy(error = err)
}
}
}
fun deletePost(slug: String) {
updateManage(slug) { it.copy(busy = true) }
viewModelScope.launch {
val err = withContext(Dispatchers.IO) { client().deletePost(slug) }
if (err == null) {
manage = manage.copy(
items = manage.items.filterNot { it.slug == slug },
message = "Moved to trash",
)
} else {
updateManage(slug) { it.copy(busy = false) }
manage = manage.copy(error = err)
}
}
}
fun clearManageNotice() {
manage = manage.copy(error = null, message = null)
}
private fun RemotePhoto.toPost(): ManagePost {
val d = runCatching { LocalDate.parse(date, isoDate) }.getOrDefault(LocalDate.now())
return ManagePost(slug = slug, caption = caption, date = d, tags = tags, thumb = thumb, url = url)
}
// Local split for optimistic UI only; the server normalizes authoritatively.
private fun parseTagList(raw: String): List<String> =
raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }
private inline fun updateManage(slug: String, transform: (ManagePost) -> ManagePost) {
manage = manage.copy(items = manage.items.map { if (it.slug == slug) transform(it) else it })
}
// ── Settings screen ─────────────────────────────────────────────────────
fun setBaseUrl(value: String) {