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:
+18
-5
@@ -1,8 +1,21 @@
|
|||||||
content/
|
/content/
|
||||||
public/
|
/public/
|
||||||
photogallery
|
# Anchored: unanchored these would also match the Kotlin package directory
|
||||||
result
|
# android/app/src/main/java/ws/inflo/photogallery/ and any nested "result".
|
||||||
result-*
|
/photogallery
|
||||||
|
/result
|
||||||
|
/result-*
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
.envrc
|
.envrc
|
||||||
.direnv/
|
.direnv/
|
||||||
|
|
||||||
|
# Android app
|
||||||
|
android/.gradle/
|
||||||
|
android/build/
|
||||||
|
android/app/build/
|
||||||
|
android/local.properties
|
||||||
|
android/.idea/
|
||||||
|
android/.kotlin/
|
||||||
|
android/keystore.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|||||||
@@ -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 <slug>.jpg` in plain text | Parses that into `<base>/photo/<slug>/` for the "Open" link |
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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.**
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Generated
+139
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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" }
|
||||||
BIN
Binary file not shown.
@@ -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
|
||||||
+251
@@ -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" "$@"
|
||||||
Vendored
+94
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user