diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..7fb1fa2
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,42 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "community.yuvomi.mobile"
+ compileSdk = 36
+ defaultConfig {
+ applicationId = "community.yuvomi.mobile"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ // Signing is deliberately supplied by the authorized release maintainer.
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ lint { abortOnError = true; warningsAsErrors = true }
+}
+
+kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
+
+dependencies {
+ implementation("androidx.activity:activity-ktx:1.10.1")
+ implementation("androidx.core:core-ktx:1.16.0")
+ implementation("androidx.webkit:webkit:1.14.0")
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test:runner:1.6.2")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..508b114
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1 @@
+# No JavaScript interfaces or reflection-based application serialization.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..fa06748
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/community/yuvomi/mobile/FileTransfer.kt b/android/app/src/main/java/community/yuvomi/mobile/FileTransfer.kt
new file mode 100644
index 0000000..4188f7e
--- /dev/null
+++ b/android/app/src/main/java/community/yuvomi/mobile/FileTransfer.kt
@@ -0,0 +1,36 @@
+package community.yuvomi.mobile
+
+import android.content.ContentResolver
+import android.net.Uri
+import java.net.URI
+import javax.net.ssl.HttpsURLConnection
+
+object FileTransfer {
+ /** Never forwards session cookies across a redirect, even to another HTTPS origin. */
+ fun save(server: ServerAddress, url: String, cookie: String?, resolver: ContentResolver, destination: Uri) {
+ require(server.owns(url))
+ val connection = URI(url).toURL().openConnection() as HttpsURLConnection
+ try {
+ connection.instanceFollowRedirects = false
+ connection.connectTimeout = 15_000; connection.readTimeout = 20_000
+ cookie?.let { connection.setRequestProperty("Cookie", it) }
+ require(connection.responseCode == 200) { "Download refused" }
+ require(connection.contentLengthLong <= 100L * 1024 * 1024) { "Download too large" }
+ connection.inputStream.use { input ->
+ requireNotNull(resolver.openOutputStream(destination, "w")).use { output ->
+ val buffer = ByteArray(8192)
+ var total = 0L
+ val deadline = System.nanoTime() + 120_000_000_000L
+ while (true) {
+ check(!Thread.currentThread().isInterrupted && System.nanoTime() < deadline)
+ val count = input.read(buffer)
+ if (count < 0) break
+ total += count
+ require(total <= 100L * 1024 * 1024)
+ output.write(buffer, 0, count)
+ }
+ }
+ }
+ } finally { connection.disconnect() }
+ }
+}
diff --git a/android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt b/android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt
new file mode 100644
index 0000000..ffc0e66
--- /dev/null
+++ b/android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt
@@ -0,0 +1,392 @@
+package community.yuvomi.mobile
+
+import android.annotation.SuppressLint
+import android.app.AlertDialog
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import android.net.http.SslError
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.provider.MediaStore
+import android.text.InputType
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.webkit.*
+import android.widget.*
+import androidx.activity.ComponentActivity
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.FileProvider
+import androidx.core.content.edit
+import androidx.core.net.toUri
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.webkit.WebStorageCompat
+import androidx.webkit.WebViewFeature
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.net.URI
+import java.util.concurrent.Executors
+
+class MainActivity : ComponentActivity() {
+ private val worker = Executors.newSingleThreadExecutor()
+ private val handler = Handler(Looper.getMainLooper())
+ private val preferences by lazy { getSharedPreferences("server", MODE_PRIVATE) }
+ private var server: ServerAddress? = null
+ private var web: WebView? = null
+ private var authDialog: AlertDialog? = null
+ private lateinit var root: LinearLayout
+ private lateinit var content: FrameLayout
+ private lateinit var progress: ProgressBar
+ private var fileCallback: ValueCallback>? = null
+ private var capture: Uri? = null
+ private var pendingDownload: String? = null
+ private var generation = 0
+ private val loadTimeout = Runnable { showError("The server is taking too long. Check your connection or try again.") }
+
+ private val picker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val callback = fileCallback
+ fileCallback = null
+ val uris = if (result.resultCode != RESULT_OK) null else {
+ val data = result.data
+ data?.clipData?.let { clip -> Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri } }
+ ?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) }
+ }
+ callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray())
+ capture = null
+ }
+ private val saveDocument = registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { destination ->
+ val url = pendingDownload
+ pendingDownload = null
+ val current = server
+ if (destination != null && url != null && current != null && current.owns(url)) {
+ val cookies = CookieManager.getInstance().getCookie(url)
+ val epoch = generation
+ worker.execute {
+ val success = runCatching { FileTransfer.save(current, url, cookies, contentResolver, destination) }.isSuccess
+ runOnUiThread {
+ if (!isDestroyed && epoch == generation) {
+ if (success) toast("Download saved") else {
+ runCatching { contentResolver.delete(destination, null, null) }
+ toast("Download failed. Check your connection and sign-in, then retry.")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Household data is hidden from screenshots and the recent-apps preview.
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ WebView.setWebContentsDebuggingEnabled(false)
+ root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
+ ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
+ val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime())
+ view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
+ insets
+ }
+ setContentView(root)
+ onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (web?.canGoBack() == true) web?.goBack()
+ else if (web != null) settings()
+ else { isEnabled = false; onBackPressedDispatcher.onBackPressed() }
+ }
+ })
+ File(cacheDir, "captures").listFiles()?.filter { it.lastModified() < System.currentTimeMillis() - 86_400_000 }
+ ?.forEach { it.delete() }
+ server = preferences.getString("origin", null)?.let { runCatching { ServerAddress.parse(it) }.getOrNull() }
+ if (server == null) onboarding() else browser()
+ }
+
+ private fun column(): LinearLayout = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(24), dp(20), dp(24), dp(20))
+ }
+ private fun text(value: String, size: Float = 17f) = TextView(this).apply {
+ text = value; textSize = size; setPadding(0, dp(8), 0, dp(8))
+ }
+ private fun button(label: String, action: () -> Unit) = Button(this).apply {
+ text = label; isAllCaps = false; minHeight = dp(48); setOnClickListener { action() }
+ }
+ private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
+
+ private fun onboarding() {
+ root.removeAllViews()
+ val form = column()
+ form.addView(text("Your home, connected", 30f))
+ form.addView(text("Connect to your own Yuvomi server. Your data stays with your household."))
+ val address = EditText(this).apply {
+ hint = "https://family.example.com"
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
+ setSingleLine(true)
+ contentDescription = "Yuvomi server URL"
+ setText(server?.origin.orEmpty())
+ id = View.generateViewId()
+ }
+ val label = text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
+ label.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
+ lateinit var connect: Button
+ connect = button("Connect securely") {
+ val candidate = runCatching { ServerAddress.parse(address.text.toString()) }.getOrElse {
+ address.error = it.message; return@button
+ }
+ connect.isEnabled = false; address.isEnabled = false; label.setText(R.string.checking_server)
+ val epoch = generation
+ worker.execute {
+ val result = runCatching { ServerProbe.check(candidate) }
+ runOnUiThread {
+ if (isDestroyed || epoch != generation) return@runOnUiThread
+ connect.isEnabled = true; address.isEnabled = true
+ if (result.isSuccess) {
+ server = candidate
+ preferences.edit { putString("origin", candidate.origin); remove("path") }
+ browser()
+ } else {
+ // Exception details may contain URL parameters; never expose them.
+ label.setText(R.string.probe_failed)
+ }
+ }
+ }
+ }
+ form.addView(address)
+ form.addView(label)
+ form.addView(connect)
+ form.addView(text("Independent community app. No telemetry or mandatory cloud service.", 14f))
+ root.addView(ScrollView(this).apply { addView(form) })
+ }
+
+ private fun browser() {
+ val current = server ?: return onboarding()
+ root.removeAllViews()
+ val toolbar = LinearLayout(this)
+ toolbar.addView(button("Server settings") { settings() })
+ toolbar.addView(button("Share server") {
+ startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"; putExtra(Intent.EXTRA_TEXT, current.origin)
+ }, "Share server address"))
+ })
+ root.addView(toolbar)
+ progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
+ contentDescription = "Loading Yuvomi"; max = 100
+ }
+ root.addView(progress, LinearLayout.LayoutParams(-1, dp(4)))
+ content = FrameLayout(this)
+ root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f))
+ val view = WebView(this)
+ web = view
+ secure(view)
+ CookieManager.getInstance().setAcceptCookie(true)
+ CookieManager.getInstance().setAcceptThirdPartyCookies(view, false)
+ view.webViewClient = object : WebViewClient() {
+ override fun shouldInterceptRequest(v: WebView, request: WebResourceRequest): WebResourceResponse? {
+ if (!request.isForMainFrame && !current.owns(request.url.toString())) {
+ return WebResourceResponse("text/plain", "UTF-8", 403, "Blocked", emptyMap(), ByteArrayInputStream(ByteArray(0)))
+ }
+ return null
+ }
+ override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
+ val url = request.url.toString()
+ if (current.owns(url)) return false
+ if (request.isForMainFrame) external(url)
+ return true
+ }
+ override fun onPageStarted(v: WebView, url: String, icon: Bitmap?) {
+ progress.visibility = View.VISIBLE
+ handler.removeCallbacks(loadTimeout); handler.postDelayed(loadTimeout, 30_000)
+ }
+ override fun onPageFinished(v: WebView, url: String) {
+ handler.removeCallbacks(loadTimeout)
+ progress.visibility = View.GONE
+ CookieManager.getInstance().flush()
+ if (current.owns(url)) {
+ val path = URI(url).rawPath.orEmpty()
+ if (!listOf("auth", "reset", "invite", "login").any { path.contains(it, true) }) {
+ preferences.edit { putString("path", path) }
+ }
+ }
+ }
+ override fun onReceivedError(v: WebView, r: WebResourceRequest, e: WebResourceError) {
+ if (r.isForMainFrame) showError("Unable to connect. Cached content may still be available. Check your network or VPN.")
+ }
+ override fun onReceivedHttpError(v: WebView, r: WebResourceRequest, e: WebResourceResponse) {
+ if (r.isForMainFrame && e.statusCode >= 400) showError("The server is unavailable (HTTP ${e.statusCode}).")
+ }
+ override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) {
+ h.cancel(); showError("The server certificate could not be verified. Contact your server administrator.")
+ }
+ override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean {
+ releaseWeb(); browser(); toast("Yuvomi recovered after a browser interruption."); return true
+ }
+ }
+ view.webChromeClient = object : WebChromeClient() {
+ override fun onProgressChanged(v: WebView, value: Int) { progress.progress = value }
+ override fun onPermissionRequest(request: PermissionRequest) { request.deny() }
+ override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {
+ callback.invoke(origin, false, false)
+ }
+ override fun onShowFileChooser(v: WebView, callback: ValueCallback>, params: FileChooserParams): Boolean {
+ if (!current.owns(v.url.orEmpty())) return false
+ fileCallback?.onReceiveValue(null)
+ fileCallback = callback
+ val types = params.acceptTypes.filter { it.matches(Regex("[a-zA-Z0-9.+*-]+/[a-zA-Z0-9.+*-]+")) }
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = types.singleOrNull() ?: "*/*"
+ if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
+ putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE)
+ }
+ val chooser = Intent.createChooser(intent, "Select a file")
+ if (types.any { it.startsWith("image/") }) {
+ val directory = File(cacheDir, "captures").apply { mkdirs() }
+ capture = FileProvider.getUriForFile(this@MainActivity, "$packageName.files", File.createTempFile("capture-", ".jpg", directory))
+ chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
+ putExtra(MediaStore.EXTRA_OUTPUT, capture)
+ addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }))
+ }
+ runCatching { picker.launch(chooser) }.onFailure {
+ fileCallback?.onReceiveValue(null); fileCallback = null; toast("No compatible file picker is available.")
+ }
+ return true
+ }
+ }
+ view.setDownloadListener { url, _, disposition, mime, _ ->
+ if (!current.owns(url)) toast("Only HTTPS downloads from your server are supported.")
+ else if (pendingDownload == null) {
+ pendingDownload = url
+ val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[^a-zA-Z0-9._ -]"), "_").take(120)
+ saveDocument.launch(name.ifBlank { "download" })
+ }
+ }
+ content.addView(view, FrameLayout.LayoutParams(-1, -1))
+ val path = preferences.getString("path", "/").orEmpty()
+ view.loadUrl(current.origin + if (path.startsWith("/") && !path.startsWith("//")) path else "/")
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun secure(view: WebView) {
+ view.settings.apply {
+ javaScriptEnabled = true; domStorageEnabled = true
+ allowFileAccess = false; allowContentAccess = false
+ mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+ javaScriptCanOpenWindowsAutomatically = false
+ setSupportMultipleWindows(false)
+ mediaPlaybackRequiresUserGesture = true
+ safeBrowsingEnabled = true
+ setGeolocationEnabled(false)
+ }
+ }
+
+ private fun external(value: String) {
+ val uri = runCatching { URI(value) }.getOrNull() ?: return
+ if (uri.scheme != "https" || uri.host.isNullOrBlank() || uri.rawUserInfo != null) {
+ toast("This link type is blocked for your security."); return
+ }
+ AlertDialog.Builder(this).setTitle("Leave your Yuvomi server?")
+ .setMessage("Destination: ${uri.host}\n\nOpen normal links in your browser. For SSO, use the isolated sign-in window only if you trust this provider. Providers that require a system browser cannot transfer a session back to this app.")
+ .setPositiveButton("Browser") { _, _ -> openBrowser(value) }
+ .setNeutralButton("Sign-in window") { _, _ -> authentication(value) }
+ .setNegativeButton("Cancel", null).show()
+ }
+
+ private fun authentication(value: String) {
+ if (authDialog != null) return
+ val auth = WebView(this)
+ secure(auth)
+ CookieManager.getInstance().setAcceptThirdPartyCookies(auth, false)
+ auth.webChromeClient = object : WebChromeClient() {
+ override fun onPermissionRequest(request: PermissionRequest) { request.deny() }
+ }
+ auth.webViewClient = object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
+ val uri = request.url
+ return uri.scheme != "https" || !uri.userInfo.isNullOrEmpty()
+ }
+ override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) { h.cancel() }
+ override fun onPageFinished(v: WebView, url: String) {
+ if (server?.owns(url) == true && !URI(url).path.startsWith("/api/")) {
+ CookieManager.getInstance().flush()
+ authDialog?.dismiss(); web?.loadUrl(url)
+ }
+ }
+ }
+ authDialog = AlertDialog.Builder(this).setTitle("Isolated web sign-in")
+ .setView(auth).setNegativeButton("Close", null).create().also { dialog ->
+ dialog.setOnDismissListener {
+ auth.stopLoading(); auth.removeAllViews(); auth.destroy(); authDialog = null
+ }
+ dialog.show()
+ dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
+ }
+ auth.loadUrl(value)
+ }
+
+ private fun openBrowser(value: String) {
+ runCatching { startActivity(Intent(Intent.ACTION_VIEW, value.toUri()).addCategory(Intent.CATEGORY_BROWSABLE)) }
+ .onFailure { toast("No browser is available.") }
+ }
+
+ private fun showError(message: String) {
+ if (isDestroyed || web == null) return
+ handler.removeCallbacks(loadTimeout)
+ progress.visibility = View.GONE
+ if (content.childCount > 1) return
+ val panel = column().apply {
+ addView(text("Connection interrupted", 25f)); addView(text(message))
+ addView(button("Retry") { content.removeView(this); web?.reload() })
+ addView(button("Keep viewing") { content.removeView(this) })
+ addView(button("Server settings") { settings() })
+ }
+ val dark = resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES
+ panel.setBackgroundColor(if (dark) 0xff191816.toInt() else 0xfff5f3ed.toInt())
+ content.addView(panel, FrameLayout.LayoutParams(-1, -1))
+ panel.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
+ ViewCompat.setAccessibilityPaneTitle(panel, "Connection interrupted")
+ }
+
+ private fun settings() {
+ AlertDialog.Builder(this).setTitle("Server settings")
+ .setMessage("${server?.origin.orEmpty()}\n\nSwitching servers erases all app cookies, storage and cached household data. Downloads saved outside the app remain. This does not revoke the server session; sign out in Yuvomi first where possible.")
+ .setPositiveButton("Erase and change server") { _, _ ->
+ generation++
+ authDialog?.dismiss(); releaseWeb()
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)) {
+ WebStorageCompat.deleteBrowsingData(WebStorage.getInstance()) {
+ CookieManager.getInstance().removeAllCookies {
+ CookieManager.getInstance().flush()
+ preferences.edit { clear() }; server = null; onboarding()
+ }
+ }
+ } else {
+ // Older engines cannot reliably erase CacheStorage/IndexedDB.
+ // OS reset is complete and scoped to this application, but closes it.
+ getSystemService(android.app.ActivityManager::class.java).clearApplicationUserData()
+ }
+ }
+ .setNeutralButton("Open server in browser") { _, _ -> server?.let { openBrowser(it.origin) } }
+ .setNegativeButton("Continue", null).show()
+ }
+
+ private fun releaseWeb() {
+ handler.removeCallbacks(loadTimeout)
+ fileCallback?.onReceiveValue(null); fileCallback = null
+ web?.let { view ->
+ view.stopLoading()
+ (view.parent as? ViewGroup)?.removeView(view)
+ view.removeAllViews(); view.destroy()
+ }
+ web = null
+ }
+ private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show()
+ override fun onPause() { web?.onPause(); CookieManager.getInstance().flush(); super.onPause() }
+ override fun onResume() { super.onResume(); web?.onResume() }
+ override fun onDestroy() {
+ generation++; authDialog?.dismiss(); releaseWeb(); worker.shutdownNow(); super.onDestroy()
+ }
+}
diff --git a/android/app/src/main/java/community/yuvomi/mobile/ServerAddress.kt b/android/app/src/main/java/community/yuvomi/mobile/ServerAddress.kt
new file mode 100644
index 0000000..fc53947
--- /dev/null
+++ b/android/app/src/main/java/community/yuvomi/mobile/ServerAddress.kt
@@ -0,0 +1,34 @@
+package community.yuvomi.mobile
+
+import java.net.URI
+import java.util.Locale
+
+/** One origin is the trust boundary. Paths are not supported by upstream. */
+class ServerAddress private constructor(val origin: String) {
+ fun owns(value: String): Boolean = runCatching {
+ val uri = URI(value)
+ uri.rawUserInfo == null && uri.scheme.equals("https", true) &&
+ uri.host.equals(URI(origin).host, true) && effectivePort(uri) == effectivePort(URI(origin))
+ }.getOrDefault(false)
+
+ companion object {
+ fun parse(input: String): ServerAddress {
+ val uri = runCatching { URI(input.trim()) }.getOrNull()
+ ?: throw IllegalArgumentException("Enter a valid HTTPS server URL.")
+ require(uri.scheme.equals("https", true) && !uri.host.isNullOrBlank()) {
+ "Use HTTPS with a valid server hostname."
+ }
+ require(uri.rawUserInfo == null && uri.rawQuery == null && uri.rawFragment == null) {
+ "Remove credentials, query parameters and fragments."
+ }
+ require(uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") {
+ "Yuvomi requires a dedicated hostname, not a subpath."
+ }
+ require(uri.port == -1 || uri.port in 1..65535) { "The port must be between 1 and 65535." }
+ val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}"
+ return ServerAddress("https://${uri.host.lowercase(Locale.ROOT)}$port")
+ }
+
+ private fun effectivePort(uri: URI) = if (uri.port == -1) 443 else uri.port
+ }
+}
diff --git a/android/app/src/main/java/community/yuvomi/mobile/ServerProbe.kt b/android/app/src/main/java/community/yuvomi/mobile/ServerProbe.kt
new file mode 100644
index 0000000..0489ceb
--- /dev/null
+++ b/android/app/src/main/java/community/yuvomi/mobile/ServerProbe.kt
@@ -0,0 +1,50 @@
+package community.yuvomi.mobile
+
+import org.json.JSONObject
+import java.net.URI
+import javax.net.ssl.HttpsURLConnection
+import java.io.ByteArrayOutputStream
+
+object ServerProbe {
+ /** No cookies, no automatic redirects, bounded response and deadlines. */
+ fun check(server: ServerAddress) {
+ val version = json(server, "/api/v1/version")
+ require(version.opt("app_name") is String && version.opt("setup_required") is Boolean &&
+ version.opt("password_reset_enabled") is Boolean) {
+ "This server does not expose compatible Yuvomi bootstrap metadata."
+ }
+ val auth = json(server, "/api/v1/auth/oidc/config")
+ require(auth.opt("enabled") is Boolean && auth.opt("password_login_enabled") is Boolean) {
+ "This server does not expose compatible Yuvomi authentication metadata."
+ }
+ }
+
+ private fun json(server: ServerAddress, path: String): JSONObject {
+ val connection = URI(server.origin + path).toURL().openConnection() as HttpsURLConnection
+ try {
+ connection.connectTimeout = 10_000
+ connection.readTimeout = 10_000
+ connection.instanceFollowRedirects = false
+ connection.setRequestProperty("Accept", "application/json")
+ require(connection.responseCode == 200) {
+ "Server unavailable or authentication proxy blocked the connection (HTTP ${connection.responseCode})."
+ }
+ val bytes = connection.inputStream.use { input ->
+ val output = ByteArrayOutputStream()
+ val buffer = ByteArray(4096)
+ val deadline = System.nanoTime() + 20_000_000_000L
+ while (true) {
+ check(System.nanoTime() < deadline)
+ val count = input.read(buffer)
+ if (count < 0) break
+ require(output.size() + count <= 65_536) { "Server metadata is too large." }
+ output.write(buffer, 0, count)
+ }
+ output.toByteArray()
+ }
+ return JSONObject(String(bytes, Charsets.UTF_8))
+ } finally {
+ connection.disconnect()
+ }
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..e566854
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..662e6c3
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..58a2819
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+ Yuvomi Mobile
+ Checking your server…
+ Cannot verify a compatible Yuvomi server. Check the URL, TLS certificate, VPN, and reverse-proxy access.
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..715812b
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..e5dc98e
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..4a515db
--- /dev/null
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..101d5e7
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/test/java/community/yuvomi/mobile/ServerAddressTest.kt b/android/app/src/test/java/community/yuvomi/mobile/ServerAddressTest.kt
new file mode 100644
index 0000000..48f3ecc
--- /dev/null
+++ b/android/app/src/test/java/community/yuvomi/mobile/ServerAddressTest.kt
@@ -0,0 +1,31 @@
+package community.yuvomi.mobile
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class ServerAddressTest {
+ @Test fun canonicalizesAndSupportsPortsAndIPv6() {
+ assertEquals("https://family.example", ServerAddress.parse(" HTTPS://Family.Example:443/ ").origin)
+ assertEquals("https://family.example:8443", ServerAddress.parse("https://family.example:8443").origin)
+ assertEquals("https://[::1]:8443", ServerAddress.parse("https://[::1]:8443").origin)
+ }
+
+ @Test fun rejectsUnsafeConfiguration() {
+ listOf("http://family.example", "javascript:alert(1)", "https://user:pass@family.example",
+ "https://family.example/subpath", "https://family.example?token=secret",
+ "https://family.example/#token", "https://family.example:0", "https://family.example:65536",
+ "https://family.example/%2f", "https://family.example\\@evil.example", "https:///", "").forEach {
+ assertThrows(it, IllegalArgumentException::class.java) { ServerAddress.parse(it) }
+ }
+ }
+
+ @Test fun navigationIsOriginBoundNotStringPrefixBound() {
+ val server = ServerAddress.parse("https://family.example")
+ assertTrue(server.owns("https://FAMILY.example:443/tasks?id=1"))
+ listOf("https://family.example.evil.test/", "https://family.example@evil.test/",
+ "https://user@family.example/", "http://family.example/", "https://family.example:8443/",
+ "file:///etc/passwd", "intent://family.example", "data:text/html,hello").forEach {
+ assertFalse(it, server.owns(it))
+ }
+ }
+}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..fef8ec1
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ id("com.android.application") version "8.13.2" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.10" apply false
+}
+
+// WSL mounted Windows volumes may reject Gradle's POSIX file metadata operations.
+// Opt-in external output root; ordinary developer/CI builds retain standard paths.
+val outputRoot = providers.environmentVariable("YUVOMI_BUILD_DIR")
+if (outputRoot.isPresent) {
+ allprojects {
+ layout.buildDirectory.set(file("${outputRoot.get()}/${project.name}"))
+ }
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..3735595
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..096fefc
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..04fd49e
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,8 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..770e0b3
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# 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" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..57f73cc
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/lint.xml b/android/lint.xml
new file mode 100644
index 0000000..5f67a53
--- /dev/null
+++ b/android/lint.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..9828438
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,7 @@
+pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories { google(); mavenCentral() }
+}
+rootProject.name = "YuvomiMobile"
+include(":app")