Add an Android uploader app

Native single-photo uploader for POST /upload. Lives in android/ with its
own flake, so the root flake stays Go-only and NixOS consumers of
nixosModules.default do not pull the Android SDK into their lock.

The server is untouched, so the app closes every gap client side:

- The slug is the enqueue-time timestamp, persisted in the work input.
  server.go derives the stored slug from the uploaded filename, so reusing
  that filename across WorkManager retries overwrites the same post rather
  than creating a duplicate. That stands in for an idempotency key.
- EXIF is stripped before upload. The gallery publishes the uploaded file
  verbatim as its full-size download, and phone photos carry GPS. JPEG and
  PNG drop metadata at marker and chunk level with the compressed pixels
  untouched; HEIC is decoded to JPEG because the extension check rejects it.
- Photos flagged for rotation are rotated into the pixels instead of relying
  on the orientation tag, which cannot survive the strip and which the
  thumbnailer ignores regardless.
- Anything over 18 MiB steps quality, then resolution, to stay inside
  nginx's client_max_body_size 20M.
- Credentials are sealed with an AES-GCM key in the Android Keystore. The
  key deliberately does not require user authentication, or background
  retries could not read it. Basic auth is attached by an interceptor
  rather than an Authenticator, which reacts to a 401 by replaying a
  multipart body that is not reliably replayable.
- The connection test uses GET /upload. /health is unreachable from outside
  because the nginx vhost only proxies location /upload.

WorkManager's SystemForegroundService needs foregroundServiceType declared
on the service entry, not just as a permission, or setForeground throws on
Android 14+. Only lintVitalRelease catches that, never a debug build.

Also anchors the .gitignore patterns. Unanchored, "photogallery" matched the
Kotlin package directory ws/inflo/photogallery/ and silently swallowed every
source file on git add.
This commit is contained in:
2026-08-13 22:01:20 +02:00
parent 70d115e11c
commit 210ce69e18
32 changed files with 2488 additions and 5 deletions
+87
View File
@@ -0,0 +1,87 @@
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
// Release signing is wired up only when android/keystore.properties exists.
// That file holds the keystore passwords and is gitignored, as is the .jks
// itself — keep both outside the repo. See android/README.md for the
// keytool invocation that creates them.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) keystorePropsFile.inputStream().use { load(it) }
}
android {
namespace = "ws.inflo.photogallery"
compileSdk = 36
defaultConfig {
applicationId = "ws.inflo.photogallery"
minSdk = 28 // ImageDecoder + HEIF decode both land here
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
signingConfigs {
if (keystorePropsFile.exists()) {
create("release") {
storeFile = rootProject.file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Falls back to unsigned when keystore.properties is absent, so a
// fresh clone still builds.
signingConfig = signingConfigs.findByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.exifinterface)
implementation(libs.okhttp)
debugImplementation(libs.androidx.ui.tooling)
}
+6
View File
@@ -0,0 +1,6 @@
# OkHttp ships Animal Sniffer and Conscrypt references that are absent at
# runtime on Android. Silence them rather than pulling in the dependencies.
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- WorkManager expedited uploads run in a foreground service on API 31+. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:largeHeap="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.PhotoGalleryUploader"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.PhotoGalleryUploader">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Share sheet target: pick a photo in Google Photos, share here. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<!-- WorkManager runs expedited work in its own foreground service. On
Android 14+ the type has to be declared on the service entry, not
just requested as a permission, or setForeground() throws. -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>
@@ -0,0 +1,121 @@
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.PickVisualMedia(),
) { uri -> uri?.let(viewModel::selectImage) }
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,
onPickImage = {
picker.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly,
),
)
},
onCaptionChange = viewModel::setCaption,
onDateChange = viewModel::setDate,
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 a photo 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)
}
uri?.let(viewModel::selectImage)
}
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()
}
}
}
@@ -0,0 +1,171 @@
package ws.inflo.photogallery.data
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Settings and credential persistence.
*
* The password is the only secret here and it never hits disk in the clear:
* it is sealed with an AES-GCM key held in the Android Keystore, so the bytes
* sitting in SharedPreferences are useless without the hardware-backed key.
*
* The key deliberately does not require user authentication. WorkManager
* retries an upload with nobody looking at the screen, and a key gated on a
* fingerprint would throw there.
*
* androidx.security-crypto (EncryptedSharedPreferences) covers this ground but
* is deprecated, so the Keystore calls are made directly.
*/
class SettingsStore(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var baseUrl: String
get() = prefs.getString(KEY_BASE_URL, "").orEmpty()
set(value) = prefs.edit().putString(KEY_BASE_URL, value.trim().trimEnd('/')).apply()
var username: String
get() = prefs.getString(KEY_USERNAME, "").orEmpty()
set(value) = prefs.edit().putString(KEY_USERNAME, value.trim()).apply()
/** Empty string when unset, or when the Keystore key no longer decrypts it. */
var password: String
get() = prefs.getString(KEY_PASSWORD, null)?.let(::decrypt).orEmpty()
set(value) = prefs.edit().putString(KEY_PASSWORD, encrypt(value)).apply()
val isConfigured: Boolean
get() = baseUrl.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty()
// ── Last successful post, shown on the post screen ──────────────────────
var lastPost: LastPost?
get() {
val slug = prefs.getString(KEY_LAST_SLUG, null) ?: return null
return LastPost(
slug = slug,
url = prefs.getString(KEY_LAST_URL, "").orEmpty(),
caption = prefs.getString(KEY_LAST_CAPTION, "").orEmpty(),
postedAtMillis = prefs.getLong(KEY_LAST_AT, 0L),
)
}
set(value) {
prefs.edit().apply {
if (value == null) {
remove(KEY_LAST_SLUG); remove(KEY_LAST_URL)
remove(KEY_LAST_CAPTION); remove(KEY_LAST_AT)
} else {
putString(KEY_LAST_SLUG, value.slug)
putString(KEY_LAST_URL, value.url)
putString(KEY_LAST_CAPTION, value.caption)
putLong(KEY_LAST_AT, value.postedAtMillis)
}
}.apply()
}
// ── Keystore sealing ────────────────────────────────────────────────────
private fun secretKey(): SecretKey {
val keystore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keystore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)
?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE,
)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(false)
.build(),
)
return generator.generateKey()
}
private fun encrypt(plain: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
val ciphertext = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) +
SEPARATOR +
Base64.encodeToString(ciphertext, Base64.NO_WRAP)
}
/**
* Returns "" rather than throwing when the sealed blob cannot be opened.
* That happens legitimately — a Keystore key can be invalidated by a device
* restore or a lock-screen change — and the right response is to prompt for
* the password again, not to crash on launch.
*/
private fun decrypt(stored: String): String = try {
val parts = stored.split(SEPARATOR, limit = 2)
if (parts.size != 2) {
""
} else {
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
} catch (_: Exception) {
""
}
companion object {
private const val PREFS = "photogallery_settings"
private const val KEY_BASE_URL = "base_url"
private const val KEY_USERNAME = "username"
private const val KEY_PASSWORD = "password_sealed"
private const val KEY_LAST_SLUG = "last_slug"
private const val KEY_LAST_URL = "last_url"
private const val KEY_LAST_CAPTION = "last_caption"
private const val KEY_LAST_AT = "last_at"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "photogallery_credentials"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_BITS = 128
private const val SEPARATOR = ":"
/**
* Returns an error message, or null when the URL is usable.
*
* https is required, not preferred. Basic auth puts the password in a
* trivially reversible header, so a plain-http base URL would hand the
* gallery password to anything on the path. The gallery's nginx vhost
* sets forceSSL anyway.
*/
fun validateBaseUrl(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return "Base URL is required"
val url = trimmed.toHttpUrlOrNull() ?: return "Not a valid URL"
if (url.scheme != "https") {
return "Must be https — plain http sends your password in the clear"
}
return null
}
}
}
data class LastPost(
val slug: String,
val url: String,
val caption: String,
val postedAtMillis: Long,
)
@@ -0,0 +1,308 @@
package ws.inflo.photogallery.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import java.io.ByteArrayOutputStream
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
/**
* Turns a picked or shared image into bytes the gallery's upload API accepts.
*
* Three things have to be true of the result: no EXIF (the gallery publishes
* the uploaded file verbatim as its "full size" download, so embedded GPS
* would be public), a .jpg or .png extension (server.go checks the extension
* and rejects anything else, HEIC included), and under nginx's 20M body cap.
*
* Where possible this is done without re-encoding, so the published file keeps
* the original sensor pixels exactly.
*/
object ImagePrep {
/** nginx is configured with client_max_body_size 20M; leave headroom. */
private const val MAX_UPLOAD_BYTES = 18L * 1024 * 1024
/**
* Cap on decoded pixels for the paths that must decode. A bitmap is 4 bytes
* per pixel, so 40MP is a ~160MB allocation — survivable with largeHeap,
* which is why the manifest asks for it. Beyond that we sample down rather
* than die.
*/
private const val MAX_DECODE_PIXELS = 40_000_000L
private const val JPEG_QUALITY = 92
/** Quality ladder walked when the encoded result overshoots the cap. */
private val QUALITY_LADDER = intArrayOf(92, 85, 75)
data class Prepared(
val file: File,
val extension: String,
/** Non-null when the app had to depart from a byte-exact copy. */
val note: String?,
)
/**
* EXIF capture date, for prefilling the date field. Read before any
* stripping happens — the same metadata block carries both this and the
* GPS tags being removed.
*/
fun captureDate(context: Context, uri: Uri): LocalDate? = try {
context.contentResolver.openInputStream(uri)?.use { stream ->
val exif = ExifInterface(stream)
val raw = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
?: exif.getAttribute(ExifInterface.TAG_DATETIME)
raw?.let { parseExifDate(it) }
}
} catch (_: Exception) {
null
}
private fun parseExifDate(raw: String): LocalDate? = try {
LocalDate.parse(raw.trim().substringBefore(' '), EXIF_DATE)
} catch (_: DateTimeParseException) {
null
}
private val EXIF_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy:MM:dd")
/**
* Produces the file to upload, written into [cacheDir].
*
* JPEG and PNG take a lossless route: whole metadata segments are dropped
* without touching the compressed image data. Anything else — HEIC being
* the common case — has to be decoded and re-encoded as JPEG.
*/
fun prepare(context: Context, uri: Uri, cacheDir: File, baseName: String): Prepared {
val source = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Could not read the selected image")
val orientation = readOrientation(context, uri)
val notes = mutableListOf<String>()
// A rotation flag cannot survive metadata stripping, and the gallery's
// thumbnailer ignores EXIF orientation regardless. Bake the rotation
// into the pixels instead, which costs a re-encode.
val mustRotate = orientation != ExifInterface.ORIENTATION_NORMAL &&
orientation != ExifInterface.ORIENTATION_UNDEFINED
val lossless: Pair<ByteArray, String>? = when {
mustRotate -> null
isJpeg(source) -> stripJpegSegments(source)?.let { it to "jpg" }
isPng(source) -> stripPngChunks(source)?.let { it to "png" }
else -> null
}
var bytes: ByteArray
var extension: String
if (lossless != null) {
bytes = lossless.first
extension = lossless.second
} else {
if (mustRotate) notes += "rotated to match its EXIF orientation"
else if (!isJpeg(source) && !isPng(source)) notes += "converted to JPEG"
bytes = encodeJpeg(decodeOriented(context, uri, orientation), JPEG_QUALITY)
extension = "jpg"
}
// Size valve. Only trips on genuinely huge originals; stepping quality
// is tried before touching resolution.
if (bytes.size > MAX_UPLOAD_BYTES) {
val bitmap = decodeOriented(context, uri, orientation)
var shrunk: ByteArray? = null
for (quality in QUALITY_LADDER) {
val attempt = encodeJpeg(bitmap, quality)
if (attempt.size <= MAX_UPLOAD_BYTES) {
shrunk = attempt
notes += "re-encoded at quality $quality to fit the 20M upload limit"
break
}
}
if (shrunk == null) {
var scaled = bitmap
var factor = 1.0
while (factor > 0.2) {
factor *= 0.75
scaled = Bitmap.createScaledBitmap(
bitmap,
(bitmap.width * factor).toInt().coerceAtLeast(1),
(bitmap.height * factor).toInt().coerceAtLeast(1),
true,
)
val attempt = encodeJpeg(scaled, QUALITY_LADDER.last())
if (attempt.size <= MAX_UPLOAD_BYTES) {
shrunk = attempt
notes += "scaled to ${scaled.width}×${scaled.height} to fit the 20M upload limit"
break
}
}
}
bytes = shrunk ?: error("Image is too large to upload even after downscaling")
extension = "jpg"
}
val file = File(cacheDir, "$baseName.$extension")
file.writeBytes(bytes)
return Prepared(
file = file,
extension = extension,
note = notes.takeIf { it.isNotEmpty() }?.joinToString("; "),
)
}
/**
* Small, correctly-rotated bitmap for the on-screen preview. Decoded at a
* sample size rather than full resolution — the preview is a thumbnail and
* has no business allocating a 40MP bitmap.
*/
fun preview(context: Context, uri: Uri, maxDimension: Int = 1024): Bitmap? = try {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
val longest = maxOf(info.size.width, info.size.height)
var sample = 1
while (longest / (sample * 2) >= maxDimension) sample *= 2
if (sample > 1) decoder.setTargetSampleSize(sample)
}
applyOrientation(bitmap, readOrientation(context, uri))
} catch (_: Exception) {
null
}
private fun readOrientation(context: Context, uri: Uri): Int = try {
context.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED,
)
} ?: ExifInterface.ORIENTATION_UNDEFINED
} catch (_: Exception) {
ExifInterface.ORIENTATION_UNDEFINED
}
private fun decodeOriented(context: Context, uri: Uri, orientation: Int): Bitmap {
val source = ImageDecoder.createSource(context.contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.isMutableRequired = false
val pixels = info.size.width.toLong() * info.size.height.toLong()
if (pixels > MAX_DECODE_PIXELS) {
var sample = 2
while (pixels / (sample.toLong() * sample) > MAX_DECODE_PIXELS) sample *= 2
decoder.setTargetSampleSize(sample)
}
}
return applyOrientation(bitmap, orientation)
}
private fun applyOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.postRotate(90f); matrix.postScale(-1f, 1f) }
ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.postRotate(270f); matrix.postScale(-1f, 1f) }
else -> return bitmap
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
private fun encodeJpeg(bitmap: Bitmap, quality: Int): ByteArray =
ByteArrayOutputStream(bitmap.width * bitmap.height / 4).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
out.toByteArray()
}
private fun isJpeg(b: ByteArray) =
b.size > 3 && b.u8(0) == 0xFF && b.u8(1) == 0xD8 && b.u8(2) == 0xFF
private fun isPng(b: ByteArray) =
b.size > 8 && b.u8(0) == 0x89 && b.u8(1) == 0x50 && b.u8(2) == 0x4E && b.u8(3) == 0x47
private fun ByteArray.u8(i: Int) = this[i].toInt() and 0xFF
/**
* Drops APP1 (EXIF and XMP) and COM segments, keeping the compressed scan
* data byte-for-byte. APP0/JFIF and APP2/ICC stay: removing the colour
* profile would shift the rendered colours.
*
* Returns null if the marker structure does not parse, in which case the
* caller falls back to a decode-and-re-encode that is guaranteed to strip.
*/
private fun stripJpegSegments(src: ByteArray): ByteArray? {
val out = ByteArrayOutputStream(src.size)
out.write(0xFF); out.write(0xD8)
var i = 2
while (i + 1 < src.size) {
if (src.u8(i) != 0xFF) return null
val marker = src.u8(i + 1)
// Standalone markers: no length field, no payload.
if (marker == 0x01 || marker in 0xD0..0xD7) {
out.write(0xFF); out.write(marker)
i += 2
continue
}
// Start of scan: entropy-coded data runs to the end of the file.
if (marker == 0xDA) {
out.write(src, i, src.size - i)
return out.toByteArray()
}
if (i + 3 >= src.size) return null
val length = (src.u8(i + 2) shl 8) or src.u8(i + 3)
if (length < 2 || i + 2 + length > src.size) return null
val isExifOrXmp = marker == 0xE1
val isComment = marker == 0xFE
if (!isExifOrXmp && !isComment) out.write(src, i, 2 + length)
i += 2 + length
}
return null
}
/**
* Drops the metadata chunks and keeps everything else, including the
* critical IHDR/PLTE/IDAT/IEND run and colour chunks like iCCP and sRGB.
* Whole chunks go, so no CRC needs recomputing.
*/
private fun stripPngChunks(src: ByteArray): ByteArray? {
val drop = setOf("eXIf", "tEXt", "zTXt", "iTXt", "tIME")
val out = ByteArrayOutputStream(src.size)
out.write(src, 0, 8)
var i = 8
while (i + 8 <= src.size) {
val length = ((src.u8(i).toLong() shl 24) or (src.u8(i + 1).toLong() shl 16) or
(src.u8(i + 2).toLong() shl 8) or src.u8(i + 3).toLong()).toInt()
if (length < 0) return null
val type = String(src, i + 4, 4, Charsets.US_ASCII)
val total = 12 + length
if (i + total > src.size) return null
if (type !in drop) out.write(src, i, total)
i += total
if (type == "IEND") return out.toByteArray()
}
return null
}
}
@@ -0,0 +1,140 @@
package ws.inflo.photogallery.net
import okhttp3.Credentials
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed interface UploadOutcome {
data class Success(val slug: String, val url: String) : UploadOutcome
/** Wrong credentials. Retrying will not help. */
data object AuthFailed : UploadOutcome
/** Server said no for a reason a retry will not change. */
data class Rejected(val code: Int, val message: String) : UploadOutcome
/** Network trouble or a 5xx. Worth retrying. */
data class Transient(val message: String) : UploadOutcome
}
/**
* Talks to the gallery's POST /upload endpoint.
*
* Behind nginx that endpoint sits under HTTP Basic auth. The credentials go
* out on every request via an interceptor rather than through OkHttp's
* [okhttp3.Authenticator], on purpose: an Authenticator only reacts to a 401
* and then replays the request, and a multipart body streamed off disk is not
* reliably replayable — the retry can arrive with an empty body.
*/
class UploadClient(
baseUrl: String,
username: String,
password: String,
) {
private val base = baseUrl.trimEnd('/')
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.MINUTES) // a full-resolution photo on mobile data
.readTimeout(60, TimeUnit.SECONDS)
.addInterceptor(BasicAuthInterceptor(username, password))
.build()
/**
* Uploads [file] as the given [fileName].
*
* The filename matters more than it looks: server.go derives the photo's
* slug from it, so passing the same name on a retry overwrites the same
* post instead of creating a duplicate.
*/
fun upload(
file: File,
fileName: String,
caption: String,
date: String,
): UploadOutcome {
val mediaType = if (fileName.endsWith(".png", ignoreCase = true)) {
"image/png".toMediaType()
} else {
"image/jpeg".toMediaType()
}
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("image", fileName, file.asRequestBody(mediaType))
.addFormDataPart("caption", caption)
.addFormDataPart("date", date)
.build()
val request = Request.Builder()
.url("$base/upload")
.post(body)
.build()
return try {
client.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
when {
response.isSuccessful -> {
val slug = parseSlug(text, fileName.substringBeforeLast('.'))
UploadOutcome.Success(slug = slug, url = "$base/photo/$slug/")
}
response.code == 401 || response.code == 403 -> UploadOutcome.AuthFailed
response.code in 500..599 ->
UploadOutcome.Transient("Server error ${response.code}")
else ->
UploadOutcome.Rejected(response.code, text.trim().ifEmpty { "Upload rejected" })
}
}
} catch (e: IOException) {
UploadOutcome.Transient(e.message ?: "Network error")
}
}
/**
* Credential check. /health is not usable for this — the nginx vhost only
* proxies location /upload, so /health falls through to the static root and
* 404s. GET /upload returns the upload page through the same Basic-auth
* gate the POST uses, which is exactly what needs testing.
*/
fun testConnection(): String? {
val request = Request.Builder().url("$base/upload").get().build()
return try {
client.newCall(request).execute().use { response ->
when {
response.isSuccessful -> null
response.code == 401 || response.code == 403 -> "Wrong username or password"
else -> "Server returned ${response.code}"
}
}
} catch (e: IOException) {
e.message ?: "Could not reach the server"
}
}
/** 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()
val slug = name.substringBeforeLast('.')
return slug.ifEmpty { fallback }
}
private class BasicAuthInterceptor(username: String, password: String) : Interceptor {
private val header = Credentials.basic(username, password)
override fun intercept(chain: Interceptor.Chain): Response =
chain.proceed(
chain.request().newBuilder()
.header("Authorization", header)
.build(),
)
}
}
@@ -0,0 +1,225 @@
package ws.inflo.photogallery.ui
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.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.HorizontalDivider
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 androidx.compose.foundation.Image
import androidx.compose.foundation.background
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 PostScreen(
state: PostUiState,
onPickImage: () -> Unit,
onCaptionChange: (String) -> Unit,
onDateChange: (LocalDate) -> Unit,
onSubmit: () -> Unit,
onReset: () -> Unit,
onOpenSettings: () -> Unit,
onOpenUrl: (String) -> Unit,
) {
var datePickerOpen by remember { mutableStateOf(false) }
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
Column(
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") }
}
Spacer(Modifier.height(12.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)
}
}
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onPickImage, modifier = Modifier.fillMaxWidth()) {
Text(if (state.imageUri == null) "Choose photo" else "Choose a different photo")
}
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)
}
Spacer(Modifier.height(8.dp))
Row {
TextButton(onClick = { onOpenUrl(status.url) }) { Text("Open") }
TextButton(onClick = onReset) { Text("Post another") }
}
}
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") }
}
}
}
if (datePickerOpen) {
DatePickerModal(
initial = state.date,
onDismiss = { datePickerOpen = false },
onConfirm = {
onDateChange(it)
datePickerOpen = false
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DatePickerModal(
initial: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate) -> Unit,
) {
val state = rememberDatePickerState(
initialSelectedDateMillis = initial
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
val millis = state.selectedDateMillis
if (millis != null) {
onConfirm(
Instant.ofEpochMilli(millis)
.atZone(ZoneId.systemDefault())
.toLocalDate(),
)
} else {
onDismiss()
}
},
) { Text("OK") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
) {
DatePicker(state = state)
}
}
@@ -0,0 +1,129 @@
package ws.inflo.photogallery.ui
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun SettingsScreen(
state: SettingsUiState,
onBaseUrlChange: (String) -> Unit,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSave: () -> Unit,
onBack: (() -> Unit)?,
) {
var passwordVisible by remember { mutableStateOf(false) }
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.baseUrl,
onValueChange = onBaseUrlChange,
label = { Text("Gallery URL") },
placeholder = { Text("https://photos.example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
Text(
"Must be https. Basic auth puts the password in a trivially " +
"reversible header, so plain http would expose it.",
color = GruvFg4,
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChange,
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text("Password") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
TextButton(onClick = { passwordVisible = !passwordVisible }) {
Text(if (passwordVisible) "Hide" else "Show")
}
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
Text(
"Stored sealed with a key held in the Android Keystore, never " +
"in the clear.",
color = GruvFg4,
style = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(24.dp))
Button(
onClick = onSave,
enabled = !state.testing,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.testing) "Testing…" else "Save and test connection")
}
state.message?.let {
Spacer(Modifier.height(12.dp))
Text(it, color = if (state.messageIsError) GruvRed else GruvAqua)
}
onBack?.let {
Spacer(Modifier.height(16.dp))
TextButton(onClick = it) { Text("← Back") }
}
}
}
}
@@ -0,0 +1,40 @@
package ws.inflo.photogallery.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Gruvbox dark hard, same values as static/style.css in the gallery.
val GruvBg = Color(0xFF1D2021)
val GruvBg0 = Color(0xFF282828)
val GruvBg1 = Color(0xFF3C3836)
val GruvBg2 = Color(0xFF504945)
val GruvFg = Color(0xFFEBDBB2)
val GruvFg3 = Color(0xFFBDAE93)
val GruvFg4 = Color(0xFFA89984)
val GruvAqua = Color(0xFF8EC07C)
val GruvGreen = Color(0xFF98971A)
val GruvBlue = Color(0xFF458588)
val GruvRed = Color(0xFFFB4934)
private val GruvboxDark = darkColorScheme(
primary = GruvAqua,
onPrimary = GruvBg,
secondary = GruvBlue,
onSecondary = GruvFg,
background = GruvBg,
onBackground = GruvFg,
surface = GruvBg0,
onSurface = GruvFg,
surfaceVariant = GruvBg1,
onSurfaceVariant = GruvFg3,
outline = GruvBg2,
error = GruvRed,
onError = GruvBg,
)
@Composable
fun PhotoGalleryTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = GruvboxDark, content = content)
}
@@ -0,0 +1,218 @@
package ws.inflo.photogallery.ui
import android.app.Application
import android.graphics.Bitmap
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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.UploadClient
import ws.inflo.photogallery.work.UploadQueue
import ws.inflo.photogallery.work.UploadWorker
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
}
data class PostUiState(
val imageUri: Uri? = null,
val preview: Bitmap? = null,
val caption: String = "",
val date: LocalDate = LocalDate.now(),
val status: PostStatus = PostStatus.Idle,
val lastPost: LastPost? = null,
)
data class SettingsUiState(
val baseUrl: String = "",
val username: String = "",
val password: String = "",
val testing: Boolean = false,
val message: String? = null,
val messageIsError: Boolean = false,
)
class UploadViewModel(app: Application) : AndroidViewModel(app) {
private val settings = SettingsStore(app)
var post by mutableStateOf(PostUiState(lastPost = settings.lastPost))
private set
var config by mutableStateOf(
SettingsUiState(
baseUrl = settings.baseUrl,
username = settings.username,
password = settings.password,
),
)
private set
val isConfigured: Boolean get() = settings.isConfigured
// ── 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())
}
}
fun setCaption(value: String) {
post = post.copy(caption = value)
}
fun setDate(value: LocalDate) {
post = post.copy(date = value)
}
fun clear() {
post = PostUiState(lastPost = settings.lastPost)
}
fun submit() {
val uri = post.imageUri ?: return
if (!settings.isConfigured) {
post = post.copy(status = PostStatus.Error("Set the server URL and credentials first"))
return
}
post = post.copy(status = PostStatus.Uploading)
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 id = UploadQueue.enqueue(
context = context,
uri = Uri.fromFile(staged),
caption = post.caption,
date = post.date,
)
observe(id)
}
}
private fun stage(uri: Uri): File? {
return try {
val context = getApplication<Application>()
val target = File(context.cacheDir, "staged-${UUID.randomUUID()}")
val copied = context.contentResolver.openInputStream(uri)?.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
if (copied == null) null else target
} catch (_: Exception) {
null
}
}
private fun observe(id: UUID) {
viewModelScope.launch {
WorkManager.getInstance(getApplication()).getWorkInfoByIdFlow(id).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,
)
}
WorkInfo.State.FAILED -> {
post = post.copy(
status = PostStatus.Error(
info.outputData.getString(UploadWorker.KEY_RESULT_ERROR)
?: "Upload failed",
),
)
}
WorkInfo.State.CANCELLED ->
post = post.copy(status = PostStatus.Error("Upload cancelled"))
else -> Unit
}
}
}
}
// ── Settings screen ─────────────────────────────────────────────────────
fun setBaseUrl(value: String) {
config = config.copy(baseUrl = value, message = null)
}
fun setUsername(value: String) {
config = config.copy(username = value, message = null)
}
fun setPassword(value: String) {
config = config.copy(password = value, message = null)
}
fun saveAndTest() {
val urlError = SettingsStore.validateBaseUrl(config.baseUrl)
if (urlError != null) {
config = config.copy(message = urlError, messageIsError = true)
return
}
if (config.username.isBlank() || config.password.isBlank()) {
config = config.copy(
message = "Username and password are required",
messageIsError = true,
)
return
}
settings.baseUrl = config.baseUrl
settings.username = config.username
settings.password = config.password
config = config.copy(testing = true, message = null)
viewModelScope.launch {
val error = withContext(Dispatchers.IO) {
UploadClient(settings.baseUrl, settings.username, settings.password)
.testConnection()
}
config = config.copy(
testing = false,
message = error ?: "Saved — server reachable and credentials accepted",
messageIsError = error != null,
)
}
}
}
@@ -0,0 +1,64 @@
package ws.inflo.photogallery.work
import android.content.Context
import android.net.Uri
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
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.
*/
private val SLUG_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")
private val ISO_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
fun enqueue(
context: Context,
uri: Uri,
caption: String,
date: LocalDate,
): UUID {
val slug = LocalDateTime.now().format(SLUG_FORMAT)
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(
Data.Builder()
.putString(UploadWorker.KEY_URI, uri.toString())
.putString(UploadWorker.KEY_SLUG, slug)
.putString(UploadWorker.KEY_CAPTION, caption)
.putString(UploadWorker.KEY_DATE, date.format(ISO_DATE))
.build(),
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
// Unique per slug, so a double tap cannot enqueue the same post twice.
WorkManager.getInstance(context).enqueueUniqueWork(
"upload-$slug",
ExistingWorkPolicy.KEEP,
request,
)
return request.id
}
}
@@ -0,0 +1,160 @@
package ws.inflo.photogallery.work
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ws.inflo.photogallery.R
import ws.inflo.photogallery.data.LastPost
import ws.inflo.photogallery.data.SettingsStore
import ws.inflo.photogallery.image.ImagePrep
import ws.inflo.photogallery.net.UploadClient
import ws.inflo.photogallery.net.UploadOutcome
/**
* Performs one upload, surviving the app being backgrounded or killed.
*
* Retries are safe by construction. The slug is generated once when the work
* is enqueued and carried in the input data, and server.go derives the stored
* filename from the uploaded filename — so a retry after an ambiguous timeout
* overwrites the same post rather than creating a second one. That is the only
* reason this works without an idempotency key on the server.
*/
class UploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val uriString = inputData.getString(KEY_URI) ?: return@withContext Result.failure()
val slug = inputData.getString(KEY_SLUG) ?: return@withContext Result.failure()
val caption = inputData.getString(KEY_CAPTION).orEmpty()
val date = inputData.getString(KEY_DATE).orEmpty()
val settings = SettingsStore(applicationContext)
if (!settings.isConfigured) {
return@withContext failWith("Set the server URL and credentials first")
}
val prepared = try {
ImagePrep.prepare(
context = applicationContext,
uri = uriString.toUri(),
cacheDir = applicationContext.cacheDir,
baseName = slug,
)
} catch (e: Exception) {
return@withContext failWith(e.message ?: "Could not read the image")
}
val client = UploadClient(
baseUrl = settings.baseUrl,
username = settings.username,
password = settings.password,
)
val outcome = client.upload(
file = prepared.file,
fileName = prepared.file.name,
caption = caption,
date = date,
)
// Keep the staged and prepared copies only while a retry might still
// need them.
if (outcome !is UploadOutcome.Transient) {
prepared.file.delete()
deleteStagedSource(uriString)
}
when (outcome) {
is UploadOutcome.Success -> {
settings.lastPost = LastPost(
slug = outcome.slug,
url = outcome.url,
caption = caption,
postedAtMillis = System.currentTimeMillis(),
)
Result.success(
androidx.work.Data.Builder()
.putString(KEY_RESULT_SLUG, outcome.slug)
.putString(KEY_RESULT_URL, outcome.url)
.putString(KEY_RESULT_NOTE, prepared.note)
.build(),
)
}
UploadOutcome.AuthFailed -> failWith("Wrong username or password")
is UploadOutcome.Rejected -> failWith(outcome.message)
is UploadOutcome.Transient ->
if (runAttemptCount < MAX_ATTEMPTS) Result.retry()
else failWith(outcome.message)
}
}
/** Removes the copy the UI staged in our cache, never anything outside it. */
private fun deleteStagedSource(uriString: String) {
val uri = uriString.toUri()
if (uri.scheme != "file") return
val file = java.io.File(uri.path ?: return)
if (file.parentFile == applicationContext.cacheDir) file.delete()
}
private fun failWith(message: String) = Result.failure(
androidx.work.Data.Builder().putString(KEY_RESULT_ERROR, message).build(),
)
override suspend fun getForegroundInfo(): ForegroundInfo {
val manager = applicationContext
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
applicationContext.getString(R.string.upload_channel_name),
NotificationManager.IMPORTANCE_LOW,
),
)
}
val notification: Notification = Notification.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(applicationContext.getString(R.string.app_name))
.setContentText("Posting photo…")
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setOngoing(true)
.build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(NOTIFICATION_ID, notification)
}
}
companion object {
const val KEY_URI = "uri"
const val KEY_SLUG = "slug"
const val KEY_CAPTION = "caption"
const val KEY_DATE = "date"
const val KEY_RESULT_SLUG = "result_slug"
const val KEY_RESULT_URL = "result_url"
const val KEY_RESULT_NOTE = "result_note"
const val KEY_RESULT_ERROR = "result_error"
private const val MAX_ATTEMPTS = 5
private const val CHANNEL_ID = "uploads"
private const val NOTIFICATION_ID = 4711
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1d2021"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Aperture blades in Gruvbox aqua on the adaptive-icon 108dp canvas.
The safe zone is the middle 72dp, so the art is inset accordingly. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#8ec07c"
android:pathData="M54,30 A24,24 0 0,1 78,54 L54,54 Z" />
<path
android:fillColor="#689d6a"
android:pathData="M78,54 A24,24 0 0,1 54,78 L54,54 Z" />
<path
android:fillColor="#8ec07c"
android:pathData="M54,78 A24,24 0 0,1 30,54 L54,54 Z" />
<path
android:fillColor="#689d6a"
android:pathData="M30,54 A24,24 0 0,1 54,30 L54,54 Z" />
<path
android:strokeColor="#ebdbb2"
android:strokeWidth="3"
android:fillColor="#00000000"
android:pathData="M54,28 A26,26 0 1,1 53.99,28 Z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- minSdk is 28, so every device resolves the v26 adaptive icon and no
density-bucket PNG fallbacks are needed. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Gruvbox dark hard, matching static/style.css in the gallery itself. -->
<resources>
<color name="gruvbox_bg">#1d2021</color>
<color name="gruvbox_bg0">#282828</color>
<color name="gruvbox_fg">#ebdbb2</color>
<color name="gruvbox_aqua">#8ec07c</color>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Gallery Upload</string>
<string name="upload_channel_name">Uploads</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Platform parent rather than Theme.Material3.* so the app does not need
the View-based material-components dependency. Compose supplies the
real theming; this only prevents a white flash before it composes. -->
<style name="Theme.PhotoGalleryUploader" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/gruvbox_bg</item>
<item name="android:statusBarColor">@color/gruvbox_bg</item>
<item name="android:navigationBarColor">@color/gruvbox_bg</item>
</style>
</resources>