feat(android): replace WebView with tested native Compose API slice

Add real native onboarding/auth/dashboard/tasks/notes/shopping, origin-bound HTTPS and encrypted AndroidKeyStore session persistence. Archive the superseded browser renderer and tests. Verify 10 JVM tests, 6 native API-35 emulator tests, lintDebug/lintRelease and debug/unsigned release APK/AAB builds; the real upstream suite passes 7 tests and exports a nonblank native fixture screenshot. Tink host-tool update removes the old generated-protobuf warning. Full module/visual parity remains explicitly incomplete.
This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-21 09:12:47 +02:00
1 parent 81973c3c45
commit 8c480ddca3
28 files changed
+2628 -742

No files matched your search

@@ -1,165 +0,0 @@
package community.yuvomi.mobile
import android.content.ContentResolver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.webkit.WebView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import java.io.OutputStream
import java.util.UUID
import java.util.WeakHashMap
import java.util.concurrent.Executor
import org.json.JSONObject
/** Same-origin, user-selected blob exports. No JS bridge or CSP/network bypass. */
object BlobTransfer {
private val stores = WeakHashMap<WebView, String>()
fun owns(server: ServerAddress, value: String): Boolean = value.startsWith("blob:") && server.owns(value.substring(5))
fun install(web: WebView, server: ServerAddress) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return
val key = "__yuvomi_blobs_${UUID.randomUUID().toString().replace("-", "") }"
stores[web] = key
val script = """
(function(){if(window.top!==window)return;
const entries=new Map();let total=0;const limit=25*1024*1024;
const original=URL.createObjectURL.bind(URL);
URL.createObjectURL=function(blob){const url=original(blob);
if(blob instanceof Blob && blob.size<=limit){
while(entries.size && (total+blob.size>limit || entries.size>=8)){
const first=entries.keys().next().value;total-=entries.get(first).size;entries.delete(first);
}
entries.set(url,blob);total+=blob.size;
}return url;};
Object.defineProperty(window,${JSONObject.quote(key)},{value:{take:function(url){
const blob=entries.get(url);if(blob){total-=blob.size;entries.delete(url);}return blob;
}}});
})();
""".trimIndent()
// Retain a bounded Blob reference even if upstream revokes the object URL
// immediately after clicking its download anchor. Never fetch a blob URL:
// upstream's CSP correctly refuses that operation in a WebView.
WebViewCompat.addDocumentStartJavaScript(web, script, setOf(server.origin))
}
fun forget(web: WebView) {
stores.remove(web)
}
fun save(
server: ServerAddress,
web: WebView,
value: String,
resolver: ContentResolver,
destination: Uri,
worker: Executor,
authorized: () -> Boolean,
completed: (Boolean) -> Unit,
) {
val store = stores[web]
if (store == null || !authorized() || !server.owns(web.url.orEmpty()) || !owns(server, value)) {
completed(false)
return
}
val handler = Handler(Looper.getMainLooper())
val key = JSONObject.quote("__yuvomi_export_${UUID.randomUUID().toString().replace("-", "")}")
val maxBytes = 25 * 1024 * 1024
var output: OutputStream? = null
var offset = 0
var finished = false
lateinit var expiry: Runnable
fun allowed() = authorized() && server.owns(web.url.orEmpty())
fun finish(success: Boolean) {
if (finished) return
finished = true
handler.removeCallbacks(expiry)
if (runCatching { allowed() }.getOrDefault(false)) {
runCatching { web.evaluateJavascript("delete window[$key]", null) }
}
val close = Runnable {
val closed = runCatching { output?.close() }.isSuccess
handler.post { completed(success && closed) }
}
runCatching { worker.execute(close) }.onFailure { close.run() }
}
expiry = Runnable { finish(false) }
lateinit var pull: Runnable
pull = Runnable {
if (!runCatching { allowed() }.getOrDefault(false)) {
finish(false)
return@Runnable
}
val script = """
(function(){const x=window[$key];
if(!x || x.state!=='ready')return {state:x?x.state:'failed'};
const b=x.bytes;if(b.length>$maxBytes)return {state:'failed'};
const end=Math.min($offset+32768,b.length);
return {state:'ready',data:btoa(String.fromCharCode.apply(null,b.subarray($offset,end))),done:end===b.length};})()
""".trimIndent()
runCatching {
web.evaluateJavascript(script) { result ->
if (finished) return@evaluateJavascript
val data = runCatching {
require(result.length < 50_000)
JSONObject(result)
}.getOrNull()
when (data?.optString("state")) {
"loading" -> handler.postDelayed(pull, 50)
"ready" -> {
val chunk = runCatching { Base64.decode(data.getString("data"), Base64.NO_WRAP) }.getOrNull()
if (chunk == null || chunk.size > 32768 || offset + chunk.size > maxBytes) {
finish(false)
} else {
offset += chunk.size
runCatching {
worker.execute {
val success = runCatching { requireNotNull(output).write(chunk) }.isSuccess
handler.post {
if (!success) {
finish(false)
} else if (data.optBoolean("done")) {
finish(true)
} else {
handler.post(pull)
}
}
}
}.onFailure { finish(false) }
}
}
else -> finish(false)
}
}
}.onFailure { finish(false) }
}
handler.postDelayed(expiry, 45_000)
runCatching {
worker.execute {
output = runCatching { resolver.openOutputStream(destination, "w") }.getOrNull()
handler.post {
if (finished) {
runCatching { output?.close() }
return@post
}
if (output == null || !allowed()) {
finish(false)
return@post
}
val script = """
(function(){window[$key]={state:'loading'};
try{const b=window[${JSONObject.quote(store)}].take(${JSONObject.quote(value)});
if(!b || b.size>$maxBytes)throw new Error('size');
b.arrayBuffer().then(a=>window[$key]={state:'ready',bytes:new Uint8Array(a)})
.catch(()=>window[$key]={state:'failed'});
}catch(e){window[$key]={state:'failed'};}})()
""".trimIndent()
runCatching { web.evaluateJavascript(script) { handler.post(pull) } }.onFailure { finish(false) }
}
}
}.onFailure { finish(false) }
}
}
@@ -1,39 +0,0 @@
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()
}
}
}
@@ -1,689 +1,31 @@
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.Manifest
import android.content.pm.PackageManager
import android.os.Build
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.CookieManager
import android.webkit.GeolocationPermissions
import android.webkit.PermissionRequest
import android.webkit.RenderProcessGoneDetail
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebStorage
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
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
import androidx.lifecycle.ViewModelProvider
/** The shipped entry point is Compose. No WebView is created or embedded. */
class MainActivity : ComponentActivity() {
private var 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 uploadSelection = SelectionGate()
private val downloadSelection = SelectionGate()
private val loadTimeout =
Runnable { showError("The server is taking too long. Check your connection or try again.") }
private val localNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
toast("Private-network access allowed. Retry your server connection.")
} else {
toast("Private-network access was not granted. Public HTTPS servers can still be used.")
}
}
private val picker =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val accepted = uploadSelection.consume(generation)
val callback = fileCallback
fileCallback = null
val uris = if (!accepted || 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())
revokeCapture()
capture = null
}
private val saveDocument =
registerForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream"),
) { destination ->
val accepted = downloadSelection.consume(generation)
val url = pendingDownload
pendingDownload = null
if (!accepted) return@registerForActivityResult
val current = server
if (destination != null && url != null && current != null &&
BlobTransfer.owns(current, url)
) {
val view = web ?: return@registerForActivityResult
val epoch = generation
BlobTransfer.save(
current,
view,
url,
contentResolver,
destination,
worker,
{ !isDestroyed && epoch == generation },
) { success ->
if (!success) runCatching { contentResolver.delete(destination, null, null) }
if (!isDestroyed &&
epoch == generation
) {
toast(
if (success) "Export saved" else "Export failed or exceeded the 25 MB limit.",
)
}
}
} else 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
if (!success) runCatching { contentResolver.delete(destination, null, null) }
runOnUiThread {
if (!isDestroyed && epoch == generation) {
if (success) {
toast("Download saved")
} else {
toast(
"Download failed. Check your connection and sign-in, then retry.",
)
}
}
}
}
}
}
private val localNetwork = registerForActivityResult(ActivityResultContracts.RequestPermission()) { recreate() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState?.getBoolean("uploadOutstanding") == true) uploadSelection.restoreOutstanding()
if (savedInstanceState?.getBoolean("downloadOutstanding") == true) downloadSelection.restoreOutstanding()
// 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) {
moveTaskToBack(true)
} 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)
localNetworkButton()?.let { form.addView(it) }
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)
BlobTransfer.install(view, current)
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(view, false)
view.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(v: WebView, request: WebResourceRequest): WebResourceResponse? {
if (!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?) {
if (!current.owns(url)) {
v.stopLoading()
external(url)
return
}
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
if (!uploadSelection.begin(generation)) {
callback.onReceiveValue(null)
toast("Finish or cancel the existing system file picker first.")
return true
}
fileCallback?.onReceiveValue(null)
fileCallback = callback
runCatching {
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)
clipData =
android.content.ClipData.newRawUri("Yuvomi photo", capture)
addFlags(
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
},
),
)
}
picker.launch(chooser)
}.onFailure {
uploadSelection.consume(generation)
fileCallback?.onReceiveValue(null)
fileCallback = null
toast("No compatible file picker is available.")
}
return true
}
}
view.setDownloadListener { url, _, disposition, mime, _ ->
if (!current.owns(url) &&
!BlobTransfer.owns(current, url)
) {
toast("Only downloads from your server are supported.")
} else if (pendingDownload == null && downloadSelection.begin(generation)) {
pendingDownload = url
val name = URLUtil.guessFileName(
url,
disposition,
mime,
).replace(Regex("[^a-zA-Z0-9._ -]"), "_").take(120)
runCatching { saveDocument.launch(name.ifBlank { "download" }) }.onFailure {
downloadSelection.consume(generation)
pendingDownload = null
toast("No compatible document provider is available.")
}
}
}
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) {
handler.removeCallbacks(loadTimeout)
progress.visibility = View.GONE
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 onPageStarted(v: WebView, url: String, favicon: Bitmap?) {
authDialog?.setTitle(
"Sign in: ${runCatching {
URI(url).host
}.getOrNull().orEmpty()}",
)
}
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),
val controller = ViewModelProvider(this)[NativeController::class.java]
setContent {
NativeApp(
controller,
needsLocalNetwork =
Build.VERSION.SDK_INT >= 37 &&
checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) != PackageManager.PERMISSION_GRANTED,
requestLocalNetwork = { if (Build.VERSION.SDK_INT >= 37) localNetwork.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) },
)
}
.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() })
localNetworkButton()?.let { addView(it) }
}
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++
worker.shutdownNow()
worker = Executors.newSingleThreadExecutor()
authDialog?.dismiss()
releaseWeb()
val captures = File(cacheDir, "captures")
val capturesErased = !captures.exists() || captures.listFiles()?.all { !it.exists() || it.delete() } == true
if (capturesErased && 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.
val reset = getSystemService(
android.app.ActivityManager::class.java,
).clearApplicationUserData()
if (!reset) {
toast("Local data could not be erased. Clear this app's storage in Android settings before switching servers.")
finish()
}
}
}
.setNeutralButton("Open server in browser") { _, _ ->
server?.let { openBrowser(it.origin) }
}
.setNegativeButton("Continue", null).show()
}
private fun localNetworkButton(): Button? {
if (android.os.Build.VERSION.SDK_INT < 37 ||
checkSelfPermission(android.Manifest.permission.ACCESS_LOCAL_NETWORK) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
return null
}
return button("Allow private-network servers") {
AlertDialog.Builder(this).setTitle("Private server access")
.setMessage(
"Android requires permission to connect to a Yuvomi server on your local network. The app connects only to your configured server; it does not scan for devices. Public internet servers do not need this permission.",
)
.setPositiveButton("Continue") { _, _ ->
localNetworkPermission.launch(android.Manifest.permission.ACCESS_LOCAL_NETWORK)
}
.setNegativeButton("Cancel", null).show()
}
}
private fun releaseWeb() {
handler.removeCallbacks(loadTimeout)
fileCallback?.onReceiveValue(null)
fileCallback = null
pendingDownload = null
revokeCapture()
capture = null
web?.let { view ->
BlobTransfer.forget(view)
view.stopLoading()
(view.parent as? ViewGroup)?.removeView(view)
view.removeAllViews()
view.destroy()
}
web = null
}
private fun revokeCapture() {
capture?.let {
revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean("uploadOutstanding", uploadSelection.outstanding)
outState.putBoolean("downloadOutstanding", downloadSelection.outstanding)
super.onSaveInstanceState(outState)
}
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,153 @@
package community.yuvomi.mobile
import java.io.ByteArrayOutputStream
import java.net.HttpCookie
import java.net.URI
import javax.net.ssl.HttpsURLConnection
import org.json.JSONArray
import org.json.JSONObject
data class SessionCookie(val name: String, val value: String, val expires: Long)
/** An explicit same-origin jar; never installs a process-wide CookieHandler. */
class NativeCookies(private val server: ServerAddress, serialized: String? = null) {
private val values = linkedMapOf<String, SessionCookie>()
init {
if (serialized != null) {
runCatching {
val rows = JSONArray(serialized)
for (index in 0 until rows.length()) {
val row = rows.getJSONObject(index)
val cookie = SessionCookie(row.getString("name"), row.getString("value"), row.getLong("expires"))
if (allowed(cookie.name) && safe(cookie.value) &&
cookie.expires > System.currentTimeMillis()
) {
values[cookie.name] = cookie
}
}
}.onFailure { values.clear() }
}
}
fun accept(headers: List<String>, now: Long = System.currentTimeMillis()) {
val host = URI(server.origin).host
for (header in headers) {
val parsed = runCatching { HttpCookie.parse(header) }.getOrDefault(emptyList())
for (cookie in parsed) {
if (!allowed(cookie.name) || !cookie.secure || !safe(cookie.value)) continue
if (cookie.domain != null && !cookie.domain.trimStart('.').equals(host, true)) continue
if (cookie.path != null && cookie.path != "/") continue
if (cookie.maxAge == 0L) {
values.remove(cookie.name)
} else {
// Session-only cookies never become persistent across launches.
val expiry = if (cookie.maxAge < 0) Long.MAX_VALUE else now + cookie.maxAge.coerceAtMost(31_536_000L) * 1000
values[cookie.name] = SessionCookie(cookie.name, cookie.value, expiry)
}
}
}
}
fun header(now: Long = System.currentTimeMillis()): String = values.values.filter {
it.expires > now
}.joinToString("; ") { "${it.name}=${it.value}" }
fun serialized(now: Long = System.currentTimeMillis()): String = JSONArray().apply {
values.values.filter { it.expires > now && it.expires != Long.MAX_VALUE }.forEach {
put(JSONObject().put("name", it.name).put("value", it.value).put("expires", it.expires))
}
}.toString()
fun clear() = values.clear()
private fun allowed(name: String) = name == "yuvomi.sid" || name == "csrf-token"
private fun safe(value: String) = value.length <= 8192 && value.none { it <= ' ' || it == ';' || it == ',' || it == '\u007f' }
}
class NativeApiError(val status: Int, val reason: String = "") : Exception() {
fun userMessage(): String = when (status) {
401 -> "Anmeldung oder Sitzung ist nicht mehr gültig."
403 -> "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen."
409 -> "Der Eintrag wurde inzwischen geändert. Bitte neu laden."
429 -> "Zu viele Versuche. Bitte warte einen Moment."
in 500..599 -> "Der Server ist derzeit nicht verfügbar."
else -> "Die Anfrage konnte nicht abgeschlossen werden (HTTP $status)."
}
}
/** Synchronous transport; the controller always invokes it on its worker thread. */
class NativeApi(val server: ServerAddress, private val cookies: NativeCookies, private val persist: (String) -> Unit) {
private var csrf = ""
fun clear() {
csrf = ""
cookies.clear()
}
fun request(path: String, method: String = "GET", body: JSONObject? = null, retry: Boolean = true): JSONObject {
require(path.startsWith("/") && !path.startsWith("//") && !path.contains('#') && !path.contains('\\'))
val target = URI(server.origin + "/api/v1" + path)
require(server.owns(target.toString()))
val connection = target.toURL().openConnection() as HttpsURLConnection
val mutation = method in setOf("POST", "PUT", "PATCH", "DELETE")
try {
connection.instanceFollowRedirects = false
connection.requestMethod = method
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("Cache-Control", "no-store")
connection.setRequestProperty("Cookie", cookies.header())
if (mutation) {
connection.setRequestProperty("Content-Type", "application/json")
connection.setRequestProperty("X-CSRF-Token", csrf)
connection.doOutput = true
connection.outputStream.use { it.write((body ?: JSONObject()).toString().toByteArray()) }
}
val status = connection.responseCode
if (status in 300..399) throw NativeApiError(status, "redirect_refused")
cookies.accept(connection.headerFields.filterKeys { it.equals("Set-Cookie", true) }.values.flatten())
persist(cookies.serialized())
val input = if (status in 200..299) connection.inputStream else connection.errorStream
val bytes = input?.use { stream ->
val output = ByteArrayOutputStream()
val buffer = ByteArray(8192)
val deadline = System.nanoTime() + 30_000_000_000L
while (true) {
check(System.nanoTime() < deadline)
val count = stream.read(buffer)
if (count < 0) break
require(output.size() + count <= 4 * 1024 * 1024)
output.write(buffer, 0, count)
}
output.toByteArray()
} ?: ByteArray(0)
val data = if (bytes.isEmpty()) {
JSONObject()
} else {
runCatching {
JSONObject(String(bytes, Charsets.UTF_8))
}.getOrElse { throw NativeApiError(status, "invalid_json") }
}
val updated = connection.getHeaderField("X-CSRF-Token") ?: data.optString("csrfToken")
if (updated.isNotBlank()) csrf = updated
if (status == 403 && mutation && retry && data.optString("reason") == "csrf_invalid") {
request("/auth/me", retry = false)
return request(path, method, body, retry = false)
}
if (status !in 200..299) throw NativeApiError(status, data.optString("reason"))
return data
} finally {
connection.disconnect()
}
}
}
fun JSONObject.rows(key: String = "data"): List<JSONObject> = optJSONArray(key)?.let { array ->
(0 until array.length()).mapNotNull { array.optJSONObject(it) }
}.orEmpty()
fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key)
fun JSONObject.countText(key: String): String = if (opt(key) is Number) get(key).toString() else "—"
@@ -0,0 +1,859 @@
package community.yuvomi.mobile
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
import org.json.JSONObject
private val Violet = Color(0xFF6C3AED)
private val PageShape = RoundedCornerShape(16.dp)
@Composable
fun NativeApp(controller: NativeController, needsLocalNetwork: Boolean = false, requestLocalNetwork: () -> Unit = {}) {
val dark = isSystemInDarkTheme()
val palette = if (dark) {
darkColorScheme(
primary = Color(0xFFA78BFA),
background = Color(0xFF191816),
surface = Color(0xFF2B2825),
onSurface = Color(0xFFF5F3ED),
onSurfaceVariant = Color(0xFFB4AEA5),
outline = Color(0xFF948E85),
onBackground = Color(0xFFF5F3ED),
)
} else {
lightColorScheme(
primary = Violet,
background = Color(0xFFF5F3ED),
surface = Color.White,
onSurface = Color(0xFF1D1B17),
onSurfaceVariant = Color(0xFF63615B),
outline = Color(0xFF8C8880),
onBackground = Color(0xFF1D1B17),
)
}
MaterialTheme(colorScheme = palette) {
val state = controller.state
var confirmForget by remember { mutableStateOf(false) }
var editor by remember { mutableStateOf<JSONObject?>(null) }
var editing by remember { mutableStateOf(false) }
var submitted by remember { mutableStateOf(false) }
var remove by remember { mutableStateOf<String?>(null) }
var info by remember { mutableStateOf<String?>(null) }
LaunchedEffect(state.user?.text("id"), state.server) {
editing = false
editor = null
submitted = false
}
LaunchedEffect(state.busy, state.error) {
if (submitted && !state.busy) {
submitted = false
if (state.error == null) editing = false
}
}
BackHandler(state.user != null && (state.page != NativePage.HOME || state.listId != null)) {
if (!state.busy) controller.navigate(if (state.listId != null) NativePage.SHOPPING else NativePage.HOME)
}
Surface(Modifier.fillMaxSize(), color = palette.background) {
if (state.server.isEmpty() || state.user == null) {
AuthenticationScreen(
state,
needsLocalNetwork,
requestLocalNetwork,
controller::connect,
controller::login,
controller::verify,
controller::cancelTwoFactor,
{ confirmForget = true }, {
info =
"SSO wird für den nativen API-Client noch nicht unterstützt. " +
"Es wird keine Browser-Sitzung als native Anmeldung vorgetäuscht."
},
)
} else {
Scaffold(
containerColor = palette.background,
bottomBar = {
Surface(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp).fillMaxWidth(),
shape = RoundedCornerShape(32.dp),
shadowElevation = 8.dp,
) {
Row(Modifier.padding(6.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
NativePage.entries.filter {
it == NativePage.HOME || it == NativePage.MORE ||
state.access(it.name.lowercase()) != "none"
}.forEach { page ->
Column(
Modifier.weight(1f).clip(CircleShape).clickable(enabled = !state.busy) {
controller.navigate(page)
}.padding(vertical = 8.dp).testTag("nav-${page.name}"),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Glyph(page, if (state.page == page) palette.primary else palette.onSurfaceVariant)
Text(
page.title,
fontSize = 11.sp,
color = if (state.page ==
page
) {
palette.primary
} else {
palette.onSurfaceVariant
},
)
}
}
}
}
},
) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (state.listId ==
null
) {
state.page.title
} else {
state.listName
},
fontSize = 34.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = controller::refresh, enabled = !state.busy) { Text("Aktualisieren") }
}
if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
state.error?.let { ErrorCard(it, controller::dismissError) }
when (state.page) {
NativePage.HOME -> DashboardScreen(state, controller::navigate)
NativePage.MORE -> MoreScreen(state, { controller.logout() }, { confirmForget = true })
else -> {
val module = state.page.name.lowercase()
val write = state.access(module) == "write" && !state.busy
Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
if (state.listId !=
null
) {
TextButton(onClick = {
controller.navigate(NativePage.SHOPPING)
}, enabled = !state.busy) { Text("Alle Listen") }
}
Spacer(Modifier.weight(1f))
if (write) {
Button(colors = nativeButtonColors(), onClick = {
editor = null
editing = true
}) {
Text(
if (state.page ==
NativePage.SHOPPING &&
state.listId == null
) {
"Neue Liste"
} else {
"Hinzufügen"
},
)
}
}
}
NativeCollection(
state,
write,
onEdit = {
editor = it
editing = true
},
onTask = controller::completeTask,
onPin = controller::pinNote,
onItem = controller::checkItem,
onList = { controller.openList(it.getLong("id"), it.text("name")) },
onRemove = { remove = it },
)
}
}
}
}
}
if (editing) {
EntryEditor(state, editor, onDismiss = { editing = false }) { title, detail, priority, date ->
when (state.page) {
NativePage.TASKS -> controller.saveTask(editor?.getLong("id"), title, detail, priority, date)
NativePage.NOTES -> controller.saveNote(editor?.getLong("id"), title, detail)
NativePage.SHOPPING -> controller.saveShopping(editor?.getLong("id"), title, detail)
else -> Unit
}
submitted = true
}
}
if (confirmForget) {
AlertDialog(
onDismissRequest = { confirmForget = false },
title = { Text("Server entfernen?") },
text = {
Text(
"Die lokale native Sitzung und geladene Inhalte werden gelöscht. " +
"Eine entfernte Sitzung wird dadurch nicht widerrufen; melde dich möglichst zuerst ab.",
)
},
confirmButton = {
TextButton(onClick = {
confirmForget = false
controller.forgetLocalServer()
}, enabled = !state.busy) { Text("Lokal entfernen") }
},
dismissButton = { TextButton(onClick = { confirmForget = false }) { Text("Abbrechen") } },
)
}
remove?.let { path ->
AlertDialog(
onDismissRequest = { remove = null },
title = { Text("Eintrag löschen?") },
text = { Text("Der Eintrag wird auf deinem Server gelöscht. Diese Aktion kann nicht zurückgenommen werden.") },
confirmButton = {
TextButton(onClick = {
remove = null
controller.remove(path)
}) { Text("Löschen") }
},
dismissButton = { TextButton(onClick = { remove = null }) { Text("Abbrechen") } },
)
}
info?.let { message ->
AlertDialog(onDismissRequest = {
info = null
}, title = { Text("Native Anmeldung") }, text = { Text(message) }, confirmButton = {
TextButton(onClick = {
info =
null
}) { Text("Verstanden") }
})
}
}
}
}
@Composable
private fun AuthenticationScreen(
state: NativeState,
needsNetwork: Boolean,
requestNetwork: () -> Unit,
connect: (String) -> Unit,
login: (String, String, String) -> Unit,
verify: (String) -> Unit,
cancelTwoFactor: () -> Unit,
forget: () -> Unit,
sso: () -> Unit,
) {
val keyboard = LocalSoftwareKeyboardController.current
var address by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var name by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var invalidAddress by remember { mutableStateOf(false) }
Column(
Modifier.fillMaxSize().imePadding().verticalScroll(rememberScrollState()).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Surface(shape = RoundedCornerShape(26.dp), modifier = Modifier.widthIn(max = 412.dp).fillMaxWidth()) {
Column(
Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
BrandMark()
Text(state.appName, fontSize = 34.sp, fontWeight = FontWeight.Bold)
Text(
if (state.server.isEmpty()) {
"Dein Zuhause. Dein Server."
} else if (state.twoFactor) {
"Bestätige deine Anmeldung"
} else {
"Alles für euren Alltag an einem Ort."
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.server.isEmpty()) {
NativeField(address, {
address = it
invalidAddress = false
}, "Server-Adresse", Modifier.testTag("server-url"), keyboard = KeyboardType.Uri)
if (invalidAddress) {
Text(
"Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.",
color = MaterialTheme.colorScheme.error,
)
}
Text(
"HTTPS · eigener Server · keine zentrale Cloud",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (needsNetwork) TextButton(onClick = requestNetwork) { Text("Zugriff auf private Netzwerke erlauben") }
Button(
colors = nativeButtonColors(),
modifier = Modifier.fillMaxWidth().height(48.dp).testTag("connect"),
enabled =
!state.busy && address.isNotBlank(),
onClick = {
keyboard?.hide()
if (runCatching { ServerAddress.parse(address) }.isSuccess) connect(address) else invalidAddress = true
},
) { Text("Verbinden") }
} else if (state.twoFactor) {
NativeField(code, { code = it }, "Code oder Wiederherstellungscode", Modifier.testTag("two-factor-code"))
Button(colors = nativeButtonColors(), onClick = {
keyboard?.hide()
verify(code)
code = ""
}, enabled = !state.busy && code.isNotBlank(), modifier = Modifier.fillMaxWidth()) { Text("Bestätigen") }
TextButton(onClick = cancelTwoFactor, enabled = !state.busy) { Text("Zurück zur Anmeldung") }
} else {
if (state.setup) NativeField(name, { name = it }, "Anzeigename")
if (state.passwordAllowed || state.setup) {
NativeField(username, { username = it }, "Benutzername", Modifier.testTag("username"))
NativeField(password, { password = it }, "Passwort", Modifier.testTag("password"), password = true)
Button(
colors = nativeButtonColors(),
onClick = {
keyboard?.hide()
val submitted = password
password = ""
login(username, submitted, name)
},
enabled =
!state.busy && username.isNotBlank() && password.isNotEmpty(),
modifier = Modifier.fillMaxWidth().height(48.dp).testTag("login"),
) {
Text(if (state.setup) "Haushalt einrichten" else "Anmelden")
}
}
if (state.oidc) TextButton(onClick = sso) { Text("SSO – noch in Umsetzung") }
TextButton(onClick = forget, enabled = !state.busy) { Text("Anderen Server verwenden") }
}
if (state.busy) CircularProgressIndicator(Modifier.size(24.dp))
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("auth-error")) }
}
}
}
}
@Composable
private fun DashboardScreen(state: NativeState, navigate: (NativePage) -> Unit) {
val data = state.dashboard
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.testTag("native-dashboard"),
) {
item {
Text(
"Hallo, ${state.user?.text("display_name").orEmpty().ifBlank {
state.user?.text("username").orEmpty()
}}",
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
LocalDate.now().format(DateTimeFormatter.ofPattern("EEEE, d. MMMM", Locale.GERMAN)),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.access("tasks") != "none" && state.widget("tasks")) {
item {
DashboardCard("Aufgaben", "${data.countText("openTaskCount")} offen", NativePage.TASKS, { navigate(NativePage.TASKS) }) {
val tasks = data.rows("urgentTasks")
if (tasks.isEmpty()) EmptyHint("Keine dringenden Aufgaben")
tasks.forEach { PreviewRow(it.text("title"), it.text("due_date")) }
}
}
}
if (state.access("calendar") != "none" && state.widget("calendar")) {
item {
DashboardCard("Kalender", "Nächste Termine", null, {}) {
val events = data.rows("upcomingEvents")
if (events.isEmpty()) EmptyHint("Keine anstehenden Termine")
events.forEach { PreviewRow(it.text("title"), it.text("start_datetime").replace('T', ' ').take(16)) }
}
}
}
if (state.access("shopping") != "none" && state.widget("shopping")) {
item {
DashboardCard("Einkauf", "${data.countText("shoppingOpenCount")} offene Artikel", NativePage.SHOPPING, {
navigate(NativePage.SHOPPING)
}) {
data.rows("shoppingLists").forEach { PreviewRow(it.text("name"), "${it.optInt("open_count")} offen") }
if (data.rows("shoppingLists").isEmpty()) EmptyHint("Alles eingekauft")
}
}
}
if (state.access("notes") != "none" && state.widget("notes")) {
item {
DashboardCard("Notizen", "Angeheftet", NativePage.NOTES, { navigate(NativePage.NOTES) }) {
val notes = data.rows("pinnedNotes")
if (notes.isEmpty()) EmptyHint("Noch keine Notizen")
notes.forEach { PreviewRow(it.text("title").ifBlank { "Notiz" }, it.text("content").take(140)) }
}
}
}
}
}
@Composable
private fun DashboardCard(
title: String,
subtitle: String,
page: NativePage?,
onClick: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(shape = PageShape, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (page != null) {
Glyph(page, MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
}
Column(Modifier.weight(1f)) {
Text(title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
Text(subtitle, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (page != null) TextButton(onClick = onClick) { Text("Alle") }
}
content()
}
}
}
@Composable
private fun NativeCollection(
state: NativeState,
write: Boolean,
onEdit: (JSONObject) -> Unit,
onTask: (JSONObject) -> Unit,
onPin: (JSONObject) -> Unit,
onItem: (JSONObject) -> Unit,
onList: (JSONObject) -> Unit,
onRemove: (String) -> Unit,
) {
var query by remember(state.page, state.listId) { mutableStateOf("") }
var status by remember { mutableStateOf("open") }
Column(Modifier.fillMaxSize()) {
NativeField(query, { query = it }, "Suchen", Modifier.padding(16.dp))
if (state.page == NativePage.TASKS) {
Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("open" to "Offen", "done" to "Erledigt", "all" to "Alle").forEach { (value, label) ->
FilterChip(
selected =
status == value,
onClick = { status = value },
label = { Text(label) },
)
}
}
}
val rows = state.rows.filter { row ->
val title = if (state.page == NativePage.SHOPPING) row.text("name") else row.text("title")
val matches = query.isBlank() || (title + row.text("content") + row.text("description")).contains(query, true)
matches &&
(
state.page != NativePage.TASKS || status == "all" ||
if (status == "done") row.text("status") == "done" else row.text("status") != "done"
)
}
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (rows.isEmpty() &&
!state.busy
) {
item { EmptyHint(if (query.isBlank()) "Hier gibt es noch keine Einträge." else "Keine passenden Einträge.") }
}
items(rows, key = { it.getLong("id") }) { row ->
Surface(shape = PageShape, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val checked = if (state.page ==
NativePage.TASKS
) {
row.text("status") == "done"
} else {
row.optInt("is_checked") == 1
}
if (state.page == NativePage.TASKS ||
(state.page == NativePage.SHOPPING && state.listId != null)
) {
Checkbox(checked, onCheckedChange = {
if (state.page ==
NativePage.TASKS
) {
onTask(row)
} else {
onItem(row)
}
}, enabled = write)
}
val title = if (state.page == NativePage.SHOPPING) row.text("name") else row.text("title").ifBlank { "Notiz" }
Column(
Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).clickable(
enabled =
!state.busy && (write || state.page == NativePage.SHOPPING && state.listId == null),
) {
if (state.page == NativePage.SHOPPING && state.listId == null) onList(row) else onEdit(row)
}.padding(4.dp),
) {
Text(
title,
fontWeight = FontWeight.SemiBold,
textDecoration = if (checked) TextDecoration.LineThrough else null,
)
val detail = when (state.page) {
NativePage.TASKS -> listOf(
row.text("category"),
row.text("due_date"),
row.text("priority").takeUnless {
it ==
"none"
}.orEmpty(),
).filter { it.isNotBlank() }.joinToString(" · ")
NativePage.NOTES -> row.text("content")
NativePage.SHOPPING -> if (state.listId !=
null
) {
listOf(row.text("quantity"), row.text("category")).filter {
it.isNotBlank()
}.joinToString(" · ")
} else {
"${row.optInt("item_total")} Artikel"
}
else -> ""
}
if (detail.isNotBlank()) Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp)
}
if (state.page == NativePage.NOTES &&
write
) {
TextButton(onClick = { onPin(row) }) { Text(if (row.optInt("pinned") == 1) "Lösen" else "Anheften") }
}
}
if (write) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = {
onRemove(
when (state.page) {
NativePage.TASKS -> "/tasks/${row.getLong("id")}"
NativePage.NOTES -> "/notes/${row.getLong("id")}"
else -> if (state.listId ==
null
) {
"/shopping/${row.getLong("id")}"
} else {
"/shopping/items/${row.getLong("id")}"
}
},
)
}) { Text("Löschen", color = MaterialTheme.colorScheme.error) }
}
}
}
}
}
}
}
}
@Composable
private fun EntryEditor(state: NativeState, entry: JSONObject?, onDismiss: () -> Unit, save: (String, String, String, String) -> Unit) {
val keyboard = LocalSoftwareKeyboardController.current
var title by remember { mutableStateOf(entry?.text(if (state.page == NativePage.SHOPPING) "name" else "title").orEmpty()) }
var detail by remember {
mutableStateOf(
entry?.text(
when (state.page) {
NativePage.NOTES -> "content"
NativePage.SHOPPING -> "quantity"
else -> "description"
},
).orEmpty(),
)
}
var priority by remember { mutableStateOf(entry?.text("priority")?.ifBlank { "none" } ?: "none") }
var date by remember { mutableStateOf(entry?.text("due_date").orEmpty()) }
val validDate = date.isBlank() || runCatching { LocalDate.parse(date) }.isSuccess
AlertDialog(onDismissRequest = { if (!state.busy) onDismiss() }, title = {
Text(
if (entry ==
null
) {
"Hinzufügen"
} else {
"Bearbeiten"
},
)
}, text = {
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
NativeField(title, { title = it }, if (state.page == NativePage.SHOPPING) "Name" else "Titel", Modifier.testTag("entry-title"))
if (state.page != NativePage.SHOPPING ||
state.listId != null
) {
NativeField(
detail,
{ detail = it },
when (state.page) {
NativePage.NOTES -> "Inhalt"
NativePage.SHOPPING -> "Menge"
else -> "Beschreibung"
},
Modifier.testTag("entry-detail"),
singleLine =
state.page == NativePage.SHOPPING,
)
}
if (state.page == NativePage.TASKS) {
Text("Priorität")
Column {
listOf(
"none" to "Keine",
"low" to "Niedrig",
"medium" to "Mittel",
"high" to "Hoch",
"urgent" to "Dringend",
).forEach { (value, label) ->
FilterChip(selected = priority == value, onClick = { priority = value }, label = { Text(label) })
}
}
NativeField(date, { date = it }, "Fällig am (JJJJ-MM-TT)")
if (!validDate) Text("Bitte ein gültiges Datum eingeben.", color = MaterialTheme.colorScheme.error)
}
}
}, confirmButton = {
TextButton(
onClick = {
keyboard?.hide()
save(title, detail, priority, date)
},
enabled =
validDate && !state.busy && (title.isNotBlank() || state.page == NativePage.NOTES && detail.isNotBlank()) &&
(state.page != NativePage.NOTES || detail.isNotBlank()),
modifier = Modifier.testTag("entry-save"),
) { Text("Speichern") }
}, dismissButton = { TextButton(onClick = onDismiss, enabled = !state.busy) { Text("Abbrechen") } })
}
@Composable
private fun MoreScreen(state: NativeState, logout: () -> Unit, forget: () -> Unit) {
LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
item {
DashboardCard("Dein Haushalt", state.appName, null, {}) {
Text(state.user?.text("display_name").orEmpty())
Text(state.server, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
item {
DashboardCard("Konto", "Native Sitzung", null, {}) {
TextButton(onClick = logout, enabled = !state.busy) { Text("Abmelden") }
TextButton(onClick = forget, enabled = !state.busy) { Text("Server und lokale Sitzung entfernen") }
}
}
item {
DashboardCard("Nativer Neubau", "Noch keine vollständige PWA-Parität", null, {}) {
Text(
"Kalender, Schichtplan, Geburtstage, Küche, Vorrat, Budget, Kontakte, Dokumente, Inventar, " +
"Haushaltshilfe, Abfall, Belohnungen, Gesundheit und Einstellungen werden separat nativ nachgebaut. " +
"Es gibt keinen Browser-Fallback.",
)
}
}
}
}
@Composable
private fun nativeButtonColors() = ButtonDefaults.buttonColors(
containerColor = if (isSystemInDarkTheme()) Color(0xFF7C3AED) else Violet,
contentColor = Color.White,
)
@Composable
private fun NativeField(
value: String,
change: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
password: Boolean = false,
keyboard: KeyboardType = KeyboardType.Text,
singleLine: Boolean = true,
) {
OutlinedTextField(
value,
change,
label = { Text(label) },
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
singleLine = singleLine,
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions = KeyboardOptions(keyboardType = if (password) KeyboardType.Password else keyboard),
)
}
@Composable
private fun EmptyHint(text: String) {
Text(text, Modifier.padding(vertical = 20.dp), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun PreviewRow(title: String, detail: String) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(title, fontWeight = FontWeight.Medium)
if (detail.isNotBlank()) Text(detail, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun ErrorCard(text: String, dismiss: () -> Unit) {
Surface(Modifier.padding(horizontal = 16.dp).fillMaxWidth(), shape = PageShape, color = MaterialTheme.colorScheme.errorContainer) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text, Modifier.weight(1f))
TextButton(onClick = dismiss) { Text("Schließen") }
}
}
}
@Composable
private fun BrandMark() {
Canvas(Modifier.size(64.dp).semantics { contentDescription = "Yuvomi" }) {
drawRoundRect(Brush.linearGradient(listOf(Color(0xFF8B5CF6), Violet)), cornerRadius = CornerRadius(size.width * .225f))
listOf(Triple(64f, 72f, 27f), Triple(100f, 78f, 25f), Triple(80f, 106f, 24f)).forEach { (x, y, radius) ->
drawCircle(Color.White.copy(alpha = .82f), radius * size.width / 160, Offset(x * size.width / 160, y * size.height / 160))
}
}
}
@Composable
private fun Glyph(page: NativePage, color: Color) {
Canvas(Modifier.size(24.dp)) {
val scale = size.width / 24
val path = Path()
when (page) {
NativePage.HOME -> {
path.moveTo(3 * scale, 10 * scale)
path.lineTo(12 * scale, 3 * scale)
path.lineTo(21 * scale, 10 * scale)
path.lineTo(21 * scale, 21 * scale)
path.lineTo(3 * scale, 21 * scale)
path.close()
}
NativePage.TASKS -> {
path.moveTo(4 * scale, 12 * scale)
path.lineTo(10 * scale, 18 * scale)
path.lineTo(21 * scale, 5 * scale)
}
NativePage.SHOPPING -> {
path.moveTo(2 * scale, 3 * scale)
path.lineTo(5 * scale, 3 * scale)
path.lineTo(8 * scale, 16 * scale)
path.lineTo(20 * scale, 16 * scale)
path.lineTo(22 * scale, 7 * scale)
path.lineTo(6 * scale, 7 * scale)
drawCircle(
color,
1.5f * scale,
Offset(9 * scale, 21 * scale),
)
drawCircle(color, 1.5f * scale, Offset(19 * scale, 21 * scale))
}
NativePage.NOTES -> {
path.moveTo(5 * scale, 3 * scale)
path.lineTo(19 * scale, 3 * scale)
path.lineTo(19 * scale, 21 * scale)
path.lineTo(5 * scale, 21 * scale)
path.close()
path.moveTo(9 * scale, 8 * scale)
path.lineTo(15 * scale, 8 * scale)
path.moveTo(9 * scale, 13 * scale)
path.lineTo(15 * scale, 13 * scale)
}
NativePage.MORE -> {
listOf(5f, 12f, 19f).forEach { drawCircle(color, 1.5f * scale, Offset(it * scale, 12 * scale)) }
}
}
drawPath(path, color, style = Stroke(width = 1.8f * scale))
}
}
@@ -0,0 +1,334 @@
package community.yuvomi.mobile
import android.app.Application
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import java.util.concurrent.Executors
import org.json.JSONObject
enum class NativePage(val title: String) {
HOME("Übersicht"),
TASKS("Aufgaben"),
SHOPPING("Einkauf"),
NOTES("Notizen"),
MORE("Mehr"),
}
data class NativeState(
val server: String = "",
val appName: String = "Yuvomi",
val user: JSONObject? = null,
val permissions: JSONObject = JSONObject(),
val setup: Boolean = false,
val passwordAllowed: Boolean = true,
val oidc: Boolean = false,
val twoFactor: Boolean = false,
val resetAllowed: Boolean = false,
val page: NativePage = NativePage.HOME,
val dashboard: JSONObject = JSONObject(),
val rows: List<JSONObject> = emptyList(),
val listId: Long? = null,
val listName: String = "",
val busy: Boolean = false,
val error: String? = null,
) {
fun access(module: String): String = if (permissions.optBoolean("admin")) {
"write"
} else {
permissions.optJSONObject("modules")?.optString(module, "write")
?: "write"
}
fun widget(id: String): Boolean = permissions.optBoolean("admin") || permissions.optJSONObject("widgets")?.optString(id) != "none"
}
class NativeController(application: Application) : AndroidViewModel(application) {
var state by mutableStateOf(NativeState())
private set
private val preferences = application.getSharedPreferences("native-server", Context.MODE_PRIVATE)
private val worker = Executors.newSingleThreadExecutor()
private val main = Handler(Looper.getMainLooper())
private var generation = 0
private var client: NativeApi? = null
private var vault: NativeSession? = null
init {
preferences.getString("origin", null)?.let { connect(it) }
}
private fun perform(clearOnFailure: Boolean = false, block: (NativeState) -> NativeState) {
if (state.busy) return
val previous = state
val selectedGeneration = generation
state = previous.copy(busy = true, error = null)
worker.execute {
val result = runCatching { block(previous) }
main.post {
if (generation != selectedGeneration) return@post
state = result.fold(
{ it.copy(busy = false) },
{ error ->
if (clearOnFailure || error is NativeApiError && error.status == 401 && previous.user != null) {
previous.copy(
user = null,
permissions = JSONObject(),
dashboard = JSONObject(),
rows = emptyList(),
page = NativePage.HOME,
listId = null,
twoFactor = false,
busy = false,
error = if (clearOnFailure) {
"Der lokale Sitzungsspeicher konnte nicht vollständig gelöscht werden. " +
"Bitte die App-Daten über Android entfernen."
} else {
"Deine Sitzung ist abgelaufen. Bitte erneut anmelden."
},
)
} else {
previous.copy(
busy = false,
error = if (error is NativeApiError) {
error.userMessage()
} else {
"Verbindung fehlgeschlagen. Bitte Server, Zertifikat und Netzwerk prüfen."
},
)
}
},
)
}
}
}
fun dismissError() {
state = state.copy(error = null)
}
fun connect(input: String) = perform {
val server = ServerAddress.parse(input)
val storage = NativeSession(getApplication(), server.origin)
val api = NativeApi(server, NativeCookies(server, storage.load()), storage::save)
val metadata = api.request("/version")
require(
metadata.opt("app_name") is String && metadata.opt("setup_required") is Boolean &&
metadata.opt("password_reset_enabled") is Boolean,
)
val oidc = api.request("/auth/oidc/config")
require(oidc.opt("enabled") is Boolean && oidc.opt("password_login_enabled") is Boolean)
client = api
vault = storage
preferences.checkedEdit { putString("origin", server.origin) }
val base = NativeState(
server = server.origin,
appName = metadata.getString("app_name"),
setup = metadata.getBoolean("setup_required"),
passwordAllowed = oidc.getBoolean("password_login_enabled") || oidc.optBoolean("guest_password_login_enabled"),
oidc = oidc.getBoolean("enabled"),
resetAllowed = metadata.getBoolean("password_reset_enabled"),
)
try {
authenticated(base, api.request("/auth/me"))
} catch (error: NativeApiError) {
if (error.status == 401) base else throw error
}
}
fun login(username: String, password: String, displayName: String = "") = perform { previous ->
val api = requireNotNull(client)
if (previous.setup) {
api.request(
"/auth/setup",
"POST",
JSONObject().put("username", username.trim()).put("password", password).put(
"display_name",
displayName.trim().ifEmpty {
username.trim()
},
),
)
}
val result = api.request("/auth/login", "POST", JSONObject().put("username", username.trim()).put("password", password))
if (result.optBoolean("twoFactorRequired")) previous.copy(setup = false, twoFactor = true) else authenticated(previous, result)
}
fun verify(code: String) = perform { previous ->
authenticated(previous, requireNotNull(client).request("/auth/2fa/verify", "POST", JSONObject().put("code", code.trim())))
}
fun cancelTwoFactor() {
if (!state.busy) state = state.copy(twoFactor = false, error = null)
}
private fun authenticated(previous: NativeState, response: JSONObject): NativeState {
val user = response.getJSONObject("user")
val permissions = response.getJSONObject("permissions")
val signedIn = previous.copy(
user = user,
permissions = permissions,
setup = false,
twoFactor = false,
page = NativePage.HOME,
dashboard = JSONObject(),
rows = emptyList(),
)
return try {
signedIn.copy(dashboard = requireNotNull(client).request("/dashboard"))
} catch (error: Exception) {
if (error is NativeApiError && error.status == 401) throw error
signedIn.copy(error = "Die Anmeldung war erfolgreich. Die Übersicht konnte noch nicht geladen werden.")
}
}
fun navigate(page: NativePage) = perform { previous -> load(previous.copy(page = page, listId = null, rows = emptyList())) }
fun refresh() = perform { previous -> load(previous) }
fun openList(id: Long, name: String) =
perform { previous -> load(previous.copy(page = NativePage.SHOPPING, listId = id, listName = name)) }
private fun load(previous: NativeState): NativeState {
val api = requireNotNull(client)
return when (previous.page) {
NativePage.HOME -> previous.copy(dashboard = api.request("/dashboard"))
NativePage.TASKS -> previous.copy(rows = api.request("/tasks").rows())
NativePage.NOTES -> previous.copy(rows = api.request("/notes").rows())
NativePage.SHOPPING -> previous.copy(rows = api.request(previous.listId?.let { "/shopping/$it/items" } ?: "/shopping").rows())
NativePage.MORE -> previous
}
}
fun saveTask(id: Long?, title: String, description: String, priority: String, date: String) = perform { previous ->
val payload = JSONObject().put("title", title.trim()).put("description", description).put("priority", priority).put(
"due_date",
date.ifBlank { null } ?: JSONObject.NULL,
)
requireNotNull(client).request(id?.let { "/tasks/$it" } ?: "/tasks", if (id == null) "POST" else "PUT", payload)
load(previous)
}
fun completeTask(task: JSONObject) = perform { previous ->
requireNotNull(client).request(
"/tasks/${task.getLong("id")}/status",
"PATCH",
JSONObject().put(
"status",
if (task.text("status") ==
"done"
) {
"open"
} else {
"done"
},
),
)
load(previous)
}
fun saveNote(id: Long?, title: String, content: String) = perform { previous ->
requireNotNull(client).request(
id?.let { "/notes/$it" } ?: "/notes",
if (id ==
null
) {
"POST"
} else {
"PUT"
},
JSONObject().put("title", title.trim()).put("content", content),
)
load(previous)
}
fun pinNote(note: JSONObject) = perform { previous ->
requireNotNull(client).request("/notes/${note.getLong("id")}/pin", "PATCH", JSONObject())
load(previous)
}
fun saveShopping(id: Long?, name: String, quantity: String) = perform { previous ->
val path = id?.let { "/shopping/items/$it" } ?: previous.listId?.let { "/shopping/$it/items" } ?: "/shopping"
requireNotNull(client).request(
path,
if (id ==
null
) {
"POST"
} else {
"PATCH"
},
JSONObject().put("name", name.trim()).apply {
if (previous.listId !=
null
) {
put("quantity", quantity)
}
},
)
load(previous)
}
fun checkItem(item: JSONObject) = perform { previous ->
requireNotNull(client).request(
"/shopping/items/${item.getLong("id")}",
"PATCH",
JSONObject().put(
"is_checked",
if (item.optInt("is_checked") ==
1
) {
0
} else {
1
},
),
)
load(previous)
}
fun remove(path: String) = perform { previous ->
requireNotNull(client).request(path, "DELETE")
load(previous)
}
fun logout(forgetServer: Boolean = false) = perform(clearOnFailure = true) { previous ->
val revoked = runCatching {
requireNotNull(client).request("/auth/logout", "POST", JSONObject())
}.isSuccess
client?.clear()
vault?.clear()
if (forgetServer) {
preferences.checkedEdit { clear() }
client = null
vault = null
NativeState()
} else {
previous.copy(
user = null,
permissions = JSONObject(),
dashboard = JSONObject(),
rows = emptyList(),
listId = null,
page = NativePage.HOME,
twoFactor = false,
error = if (revoked) null else "Lokal abgemeldet. Die Abmeldung auf dem Server konnte nicht bestätigt werden.",
)
}
}
fun forgetLocalServer() = perform(clearOnFailure = true) {
client?.clear()
vault?.clear()
preferences.checkedEdit { clear() }
client = null
vault = null
NativeState()
}
override fun onCleared() {
generation++
worker.shutdownNow()
super.onCleared()
}
}
@@ -0,0 +1,63 @@
package community.yuvomi.mobile
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/** Native cookies are encrypted with an OS-keystore key and bound to one origin. */
class NativeSession(context: Context, private val origin: String) {
private val preferences = context.getSharedPreferences("native-session", Context.MODE_PRIVATE)
private val alias = "yuvomi-native-session-v1"
private fun key(): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(alias, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
}.generateKey()
}
fun load(): String? = runCatching {
val encoded = preferences.getString(origin, null) ?: return null
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
require(bytes.size > 28)
Cipher.getInstance("AES/GCM/NoPadding").run {
init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12)))
updateAAD(origin.toByteArray())
String(doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8)
}
}.getOrNull()
fun save(value: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key())
cipher.updateAAD(origin.toByteArray())
val encoded = Base64.encodeToString(cipher.iv + cipher.doFinal(value.toByteArray()), Base64.NO_WRAP)
preferences.checkedEdit { putString(origin, encoded) }
}
fun clear() {
preferences.checkedEdit { remove(origin) }
}
}
/** Unlike KTX edit(commit=true), this does not discard a failed persistence result. */
@SuppressLint("UseKtx")
fun SharedPreferences.checkedEdit(change: SharedPreferences.Editor.() -> Unit) {
val editor = edit()
editor.change()
check(editor.commit())
}
@@ -1,23 +0,0 @@
package community.yuvomi.mobile
/** Never delivers a late system-picker result into a different server/web context. */
class SelectionGate {
private var owner: Int? = null
val outstanding: Boolean get() = owner != null
fun begin(generation: Int): Boolean {
if (outstanding) return false
owner = generation
return true
}
fun consume(generation: Int): Boolean {
val previous = owner
owner = null
return previous == generation
}
fun restoreOutstanding() {
owner = -1
}
}