feat(KAN-55): support app links, file selection and clipboard
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-09-26 20:58:29 +02:00
parent 8bed02a484
commit 9d472b5091
18 changed files with 501 additions and 9 deletions
@@ -1,6 +1,8 @@
package de.kanbangit.desktop
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
@@ -8,10 +10,12 @@ import android.net.http.SslError
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.SslErrorHandler
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
@@ -24,8 +28,10 @@ import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -50,6 +56,47 @@ class MainActivity : TauriActivity() {
private var keyboardVisible = false
private var initialized = false
private var fileCallback: ValueCallback<Array<Uri>>? = null
private var fileWorkspace: WebView? = null
private var filePage: String? = null
private var multipleFiles = false
private var filePickerActive = false
private val popups = mutableSetOf<WebView>()
private val filePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
filePickerActive = false
val callback = fileCallback
val owner = fileWorkspace
val page = filePage
fileCallback = null
fileWorkspace = null
filePage = null
val files = WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data)
val valid = owner != null && owner === workspace && owner.url == page &&
NavigationPolicy.allows(server, page.orEmpty()) && files?.isNotEmpty() == true &&
(multipleFiles || files.size == 1) && files.all { uri ->
NavigationPolicy.selectedFile(uri.toString(), packageName) && runCatching {
contentResolver.openFileDescriptor(uri, "r")?.use { true } == true
}.getOrDefault(false)
}
callback?.onReceiveValue(if (valid) files else null)
}
private fun cancelFileSelection() {
fileCallback?.onReceiveValue(null)
fileCallback = null
fileWorkspace = null
filePage = null
}
private fun openExternal(url: String) {
if (!NavigationPolicy.external(url)) return
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE))
} catch (_: ActivityNotFoundException) {
Toast.makeText(this, R.string.external_link_error, Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -144,6 +191,7 @@ class MainActivity : TauriActivity() {
showWorkspace()
return
}
cancelFileSelection()
val changed = address != preferences.getString("server", null)
val clearSession = changed || preferences.getBoolean("clearing", false)
if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) {
@@ -187,18 +235,67 @@ class MainActivity : TauriActivity() {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = false
allowContentAccess = false
allowContentAccess = true
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(true)
}
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
remote.webChromeClient = WebChromeClient()
remote.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(view: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
if (view !== workspace || !NavigationPolicy.allows(server, view.url.orEmpty()) ||
params.mode !in listOf(FileChooserParams.MODE_OPEN, FileChooserParams.MODE_OPEN_MULTIPLE)) return false
// Keep one outstanding picker; stale results must never reach a different page.
if (filePickerActive) return false
fileCallback = callback
fileWorkspace = view
filePage = view.url
multipleFiles = params.mode == FileChooserParams.MODE_OPEN_MULTIPLE
return try {
filePickerActive = true
filePicker.launch(params.createIntent().addCategory(Intent.CATEGORY_OPENABLE))
true
} catch (_: ActivityNotFoundException) {
filePickerActive = false
cancelFileSelection()
Toast.makeText(this@MainActivity, R.string.file_picker_error, Toast.LENGTH_LONG).show()
true
}
}
override fun onCreateWindow(view: WebView, isDialog: Boolean, isUserGesture: Boolean, result: Message): Boolean {
if (view !== workspace || !isUserGesture || !NavigationPolicy.allows(server, view.url.orEmpty())) return false
val popup = WebView(this@MainActivity)
popups.add(popup)
fun dispose() {
if (popups.remove(popup)) popup.destroy()
}
popup.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(child: WebView, request: WebResourceRequest): Boolean {
if (request.isForMainFrame && workspace === view) {
val url = request.url.toString()
if (NavigationPolicy.allows(server, url)) view.loadUrl(url) else openExternal(url)
}
child.post { dispose() }
return true
}
}
(result.obj as WebView.WebViewTransport).webView = popup
result.sendToTarget()
handler.postDelayed({ dispose() }, 10000)
return true
}
}
remote.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
!NavigationPolicy.allows(server, request.url.toString())
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
if (NavigationPolicy.allows(server, url)) return false
if (workspace === view && request.isForMainFrame && request.hasGesture()) openExternal(url)
return true
}
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
if (workspace === view) cancelFileSelection()
if (!NavigationPolicy.allows(server, url)) {
view.stopLoading()
fail(view)
@@ -299,6 +396,9 @@ class MainActivity : TauriActivity() {
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
cancelFileSelection()
popups.forEach { it.destroy() }
popups.clear()
workspace?.destroy()
workspace = null
super.onDestroy()
@@ -31,6 +31,21 @@ object NavigationPolicy {
target.rawUserInfo == null && !destination.contains('\\')
}.getOrDefault(false)
fun external(destination: String): Boolean = runCatching {
val target = URI(destination)
target.scheme.lowercase(Locale.ROOT) in setOf("https", "http") &&
!target.host.isNullOrBlank() && target.rawUserInfo == null &&
(target.port == -1 || target.port in 1..65535) &&
destination.none { it.isISOControl() || it == '\\' }
}.getOrDefault(false)
fun selectedFile(destination: String, packageName: String): Boolean = runCatching {
val target = URI(destination)
target.scheme == "content" && !target.host.isNullOrBlank() &&
target.rawUserInfo == null && target.port == -1 &&
target.host != packageName && target.host != "$packageName.fileprovider"
}.getOrDefault(false)
fun backAction(keyboard: Boolean, launcher: Boolean, ready: Boolean, history: Boolean): String = when {
keyboard -> "keyboard"
launcher && ready -> "resume"
@@ -3,4 +3,6 @@
<string name="main_activity_title">"KanbanGit"</string>
<string name="server_settings">Server settings</string>
<string name="update_webview">Update Android System WebView to connect to your workspace.</string>
<string name="external_link_error">No browser is available to open this link.</string>
<string name="file_picker_error">No file picker is available. Please try again.</string>
</resources>
@@ -23,6 +23,24 @@ class NavigationPolicyTest {
assertTrue(NavigationPolicy.allows("https://example.com/", "https://example.com:443/login"))
}
@Test fun externalLinksNeverBecomeServerNavigation() {
val server = "https://example.com/"
for (url in listOf("https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other")) {
assertTrue(url, NavigationPolicy.external(url))
assertFalse(url, NavigationPolicy.allows(server, url))
}
for (url in listOf("https://user:password@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "intent://example.com/", "file:///etc/passwd", "content://provider/file", "data:text/html,test", "mailto:user@example.com", "https://example.com\\evil")) {
assertFalse(url, NavigationPolicy.external(url))
}
}
@Test fun filePickerAcceptsOnlyExternalContentProviders() {
assertTrue(NavigationPolicy.selectedFile("content://com.android.providers.media.documents/document/image%3A1", "de.kanbangit.desktop"))
for (url in listOf("file:///data/data/de.kanbangit.desktop/session", "https://example.com/avatar.png", "content://de.kanbangit.desktop.fileprovider/private/key", "content://de.kanbangit.desktop/private/key", "content://user@provider/path", "content://provider:123/path", "content:/missing-provider")) {
assertFalse(url, NavigationPolicy.selectedFile(url, "de.kanbangit.desktop"))
}
}
@Test fun backClosesKeyboardBeforeNavigatingAndPreservesTheWorkspace() {
assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true))
assertEquals("resume", NavigationPolicy.backAction(false, true, true, true))