feat(android): implement secure self-hosted companion and release builds

Add native HTTPS onboarding, origin-bound WebView, isolated web authentication, persistent sessions, upload/camera picker, bounded authenticated downloads and recovery. Validate JVM policy tests, debug APK, lint and unsigned release APK/AAB. Build outputs can be relocated for WSL.
This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-20 23:24:45 +02:00
1 parent fbcc220869
commit ed8d5cb1c1
23 files changed
+1049

No files matched your search

+42
View File
@@ -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")
}
+1
View File
@@ -0,0 +1 @@
# No JavaScript interfaces or reflection-based application serialization.
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:theme="@style/AppTheme"
android:supportsRtl="true">
<activity android:name=".MainActivity" android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files" android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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() }
}
}
@@ -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<Array<Uri>>? = 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<Array<Uri>>, 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()
}
}
@@ -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
}
}
@@ -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()
}
}
}
@@ -0,0 +1,4 @@
<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="#6C3AED" android:pathData="M0,0h108v108h-108z" />
<path android:strokeColor="#FFFFFF" android:strokeWidth="9" android:strokeLineCap="round" android:fillColor="@android:color/transparent" android:pathData="M32,29L54,54L76,29M54,54L54,80" />
</vector>
@@ -0,0 +1,8 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#A78BFA</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowBackground">#191816</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">Yuvomi Mobile</string>
<string name="checking_server">Checking your server…</string>
<string name="probe_failed">Cannot verify a compatible Yuvomi server. Check the URL, TLS certificate, VPN, and reverse-proxy access.</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#6C3AED</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowBackground">#F5F3ED</item>
</style>
</resources>
@@ -0,0 +1,16 @@
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,3 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="captures" path="captures/" />
</paths>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors><certificates src="system" /></trust-anchors>
</base-config>
</network-security-config>
@@ -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))
}
}
}
+13
View File
@@ -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}"))
}
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+8
View File
@@ -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
Vendored Executable
+251
View File
@@ -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" "$@"
+94
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
<lint>
<!-- Deliberately pinned, tested dependencies; upgrades are reviewed in separate
changes. Known vulnerabilities are checked independently with OSV in CI.
A newer release is not itself a correctness or security failure. -->
<issue id="GradleDependency" severity="informational" />
<issue id="AndroidGradlePluginVersion" severity="informational" />
</lint>
+7
View File
@@ -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")