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