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

+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>