diff --git a/.gitignore b/.gitignore index a16ea06..4a584a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,21 @@ -content/ -public/ -photogallery -result -result-* +/content/ +/public/ +# Anchored: unanchored these would also match the Kotlin package directory +# android/app/src/main/java/ws/inflo/photogallery/ and any nested "result". +/photogallery +/result +/result-* *.tar.gz .envrc .direnv/ + +# Android app +android/.gradle/ +android/build/ +android/app/build/ +android/local.properties +android/.idea/ +android/.kotlin/ +android/keystore.properties +*.jks +*.keystore diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..ed9c399 --- /dev/null +++ b/android/README.md @@ -0,0 +1,67 @@ +# 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. + +The server is not modified by this app. Everything it needs, it does client +side. + +## Build + +```bash +cd android +nix develop # Android SDK 36, build-tools 36.0.0, JDK 17, gradle +gradle wrapper # first time only, generates ./gradlew +./gradlew assembleDebug +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +The dev shell exports `ANDROID_HOME`, `JAVA_HOME` and a `GRADLE_OPTS` that +points AGP at the nix-provided `aapt2`. Without that last one AGP downloads a +generic-linux `aapt2` from Maven which will not run on NixOS. + +## Release signing + +`app/build.gradle.kts` wires up a release signing config only when +`android/keystore.properties` exists. Both that file and `*.jks` are +gitignored — keep the keystore outside the repo and back it up somewhere you +will still have in five years, because losing it means you can never upgrade an +installed build in place. + +```bash +mkdir -p ~/keys +keytool -genkeypair -v \ + -keystore ~/keys/photogallery-release.jks \ + -alias photogallery \ + -keyalg RSA -keysize 4096 -validity 10000 +``` + +Then write `android/keystore.properties`: + +```properties +storeFile=/home/you/keys/photogallery-release.jks +storePassword=… +keyAlias=photogallery +keyPassword=… +``` + +```bash +./gradlew assembleRelease +``` + +Without `keystore.properties` the release build still runs, it just comes out +unsigned. + +## How it maps onto the server + +| Server behaviour | What the app does about it | +|---|---| +| Slug derived from the uploaded filename | Sends `yyyy-MM-dd-HHmmss.jpg`, generated once at enqueue | +| Same filename overwrites the same post | Reused across retries, which makes retries idempotent | +| Uploaded file published verbatim as "full size" | Strips EXIF, including GPS, before upload | +| Extension check rejects HEIC | Converts HEIC to JPEG; JPEG and PNG pass through losslessly | +| nginx `client_max_body_size 20M` | Steps quality, then resolution, if the result exceeds 18 MiB | +| Thumbnailer ignores EXIF orientation | Bakes rotation into the pixels rather than relying on the tag | +| nginx only proxies `/upload`, so `/health` 404s | Tests credentials with `GET /upload` instead | +| Replies `uploaded .jpg` in plain text | Parses that into `/photo//` for the "Open" link | diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..e7d3c39 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..9a92234 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..04da65b --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt new file mode 100644 index 0000000..17452ce --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/MainActivity.kt @@ -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() + } + } +} diff --git a/android/app/src/main/java/ws/inflo/photogallery/data/SettingsStore.kt b/android/app/src/main/java/ws/inflo/photogallery/data/SettingsStore.kt new file mode 100644 index 0000000..93a99de --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/data/SettingsStore.kt @@ -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, +) diff --git a/android/app/src/main/java/ws/inflo/photogallery/image/ImagePrep.kt b/android/app/src/main/java/ws/inflo/photogallery/image/ImagePrep.kt new file mode 100644 index 0000000..9ab2fed --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/image/ImagePrep.kt @@ -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() + + // 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? = 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 + } +} diff --git a/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt b/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt new file mode 100644 index 0000000..7b23cc5 --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/net/UploadClient.kt @@ -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 ."; 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(), + ) + } +} diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt new file mode 100644 index 0000000..046a3c8 --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/PostScreen.kt @@ -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) + } +} diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/SettingsScreen.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/SettingsScreen.kt new file mode 100644 index 0000000..e0b28de --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/SettingsScreen.kt @@ -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") } + } + } + } +} diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/Theme.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/Theme.kt new file mode 100644 index 0000000..ed9bba7 --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/Theme.kt @@ -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) +} diff --git a/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt new file mode 100644 index 0000000..437ab10 --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/ui/UploadViewModel.kt @@ -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() + 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() + + // 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() + 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, + ) + } + } +} diff --git a/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt b/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt new file mode 100644 index 0000000..120bf75 --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/work/UploadQueue.kt @@ -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() + .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 + } +} diff --git a/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt b/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt new file mode 100644 index 0000000..70837c3 --- /dev/null +++ b/android/app/src/main/java/ws/inflo/photogallery/work/UploadWorker.kt @@ -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 + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..0247c54 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..5fb7b02 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bca081d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..6f3625b --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + + #1d2021 + #282828 + #ebdbb2 + #8ec07c + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..de34590 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Gallery Upload + Uploads + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1b3a152 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..9deb573 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/android/flake.lock b/android/flake.lock new file mode 100644 index 0000000..9fc6684 --- /dev/null +++ b/android/flake.lock @@ -0,0 +1,139 @@ +{ + "nodes": { + "android-nixpkgs": { + "inputs": { + "devshell": "devshell", + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786567347, + "narHash": "sha256-I52DnM27HeCMJMgCEyNb5wz8prz6GOjzIxJnvF0ezD0=", + "owner": "tadfisher", + "repo": "android-nixpkgs", + "rev": "ccdf76eeb9011ad795cca84d1cc4bd9baa9a7329", + "type": "github" + }, + "original": { + "owner": "tadfisher", + "ref": "stable", + "repo": "android-nixpkgs", + "type": "github" + } + }, + "devshell": { + "inputs": { + "nixpkgs": [ + "android-nixpkgs", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768818222, + "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", + "owner": "numtide", + "repo": "devshell", + "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1786599213, + "narHash": "sha256-yNJd40f11EzXBjSByCB7IPpeFFAdeoSKKM67dGkfFoU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "android-nixpkgs": "android-nixpkgs", + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/android/flake.nix b/android/flake.nix new file mode 100644 index 0000000..3607319 --- /dev/null +++ b/android/flake.nix @@ -0,0 +1,60 @@ +{ + description = "photogallery Android uploader dev shell"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + + # Reproducible Android SDK across NixOS + macOS without the nixpkgs + # androidenv read-only-SDK-root quirks. + android-nixpkgs = { + url = "github:tadfisher/android-nixpkgs/stable"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, flake-utils, android-nixpkgs }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + config.allowUnfree = true; # Android SDK is unfree + }; + + buildToolsVersion = "36.0.0"; + + # SDK components for the Compose app. Pinned to the exact versions + # the Gradle config requests — adding extras only inflates the + # closure. When AGP bumps any of these, the build error names the + # exact missing version: look it up in `android-nixpkgs` and replace + # the line. No NDK or CMake: there is no native code in this app. + androidSdk = android-nixpkgs.sdk.${system} (sdkPkgs: with sdkPkgs; [ + cmdline-tools-latest # sdkmanager, avdmanager + platform-tools # adb, for sideloading to the phone + platforms-android-36 # compileSdk / targetSdk + build-tools-36-0-0 # aapt2, d8, zipalign + build-tools-35-0-0 # AGP 8.x resolves this one too, and the + # nix store is read-only so it cannot + # download it itself + ]); + in { + devShells.default = pkgs.mkShell { + packages = [ + pkgs.jdk17 # AGP 8.x toolchain + pkgs.gradle # bootstraps ./gradlew, then the wrapper takes over + androidSdk + ]; + + shellHook = '' + export ANDROID_HOME="${androidSdk}/share/android-sdk" + export ANDROID_SDK_ROOT="$ANDROID_HOME" + export JAVA_HOME="${pkgs.jdk17.home}" + + # AGP downloads aapt2 from Maven by default. The Maven binary is + # generic-linux ELF and won't run on NixOS. Force Gradle to use the + # patchelfed aapt2 shipped in our pinned build-tools instead. + export GRADLE_OPTS="-Dorg.gradle.project.android.aapt2FromMavenOverride=$ANDROID_HOME/build-tools/${buildToolsVersion}/aapt2" + ''; + }; + }); +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..487be95 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g +org.gradle.parallel=true +org.gradle.caching=true + +android.useAndroidX=true +android.nonTransitiveRClass=true + +kotlin.code.style=official diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..3c037af --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,30 @@ +[versions] +agp = "8.13.0" +kotlin = "2.2.20" +coreKtx = "1.17.0" +lifecycle = "2.9.4" +activityCompose = "1.11.0" +composeBom = "2025.09.00" +work = "2.10.5" +okhttp = "4.12.0" +exifinterface = "1.4.1" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..aaaabb3 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..1f680d8 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "photogallery-uploader" +include(":app")