Some checks failed
build / apk (push) Has been cancelled
- window.NikoGPTAndroid bridge: the web app asks the app for the native account picker; the returned ID token is posted through the WebView - falls back to the browser flow (nikogpt:// deep link handoff) when the provider is missing (microG) or the user cancels - version 1.0.3
333 lines
13 KiB
Kotlin
333 lines
13 KiB
Kotlin
package dev.n1ko.nikogpt
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.app.DownloadManager
|
|
import android.content.ActivityNotFoundException
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.net.Uri
|
|
import android.net.http.SslError
|
|
import android.os.Bundle
|
|
import android.os.Environment
|
|
import android.view.View
|
|
import android.webkit.CookieManager
|
|
import android.webkit.DownloadListener
|
|
import android.webkit.JavascriptInterface
|
|
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.WebSettings
|
|
import android.webkit.WebView
|
|
import android.webkit.WebViewClient
|
|
import android.widget.Button
|
|
import android.widget.LinearLayout
|
|
import android.widget.ProgressBar
|
|
import android.widget.TextView
|
|
import android.widget.Toast
|
|
import androidx.activity.OnBackPressedCallback
|
|
import androidx.activity.result.ActivityResultLauncher
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.core.view.ViewCompat
|
|
import androidx.core.view.WindowCompat
|
|
import androidx.core.view.WindowInsetsCompat
|
|
import androidx.core.view.updatePadding
|
|
import androidx.credentials.CredentialManager
|
|
import androidx.credentials.GetCredentialRequest
|
|
import androidx.credentials.exceptions.GetCredentialException
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
|
import kotlinx.coroutines.launch
|
|
|
|
/**
|
|
* The whole app: a thin, open-source WebView around the NikoGPT web chat.
|
|
*
|
|
* Only chat.n1ko.dev is loaded inside; every other link opens in the system
|
|
* browser. File attachments go through the system document picker, downloads
|
|
* through DownloadManager. TLS errors are never bypassed.
|
|
*/
|
|
class MainActivity : AppCompatActivity() {
|
|
|
|
companion object {
|
|
private const val START_URL = "https://chat.n1ko.dev/"
|
|
private const val HOST = "chat.n1ko.dev"
|
|
private const val UA_SUFFIX = " NikoGPT-Android/1.0"
|
|
}
|
|
|
|
private lateinit var webView: WebView
|
|
private lateinit var refresh: SwipeRefreshLayout
|
|
private lateinit var progress: ProgressBar
|
|
private lateinit var errorBox: LinearLayout
|
|
private lateinit var errorText: TextView
|
|
|
|
private var fileCallback: ValueCallback<Array<Uri>>? = null
|
|
private val filePicker: ActivityResultLauncher<Array<String>> =
|
|
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
|
fileCallback?.onReceiveValue(uris?.toTypedArray() ?: arrayOf())
|
|
fileCallback = null
|
|
}
|
|
|
|
@SuppressLint("SetJavaScriptEnabled")
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
setContentView(R.layout.activity_main)
|
|
|
|
webView = findViewById(R.id.webview)
|
|
refresh = findViewById(R.id.refresh)
|
|
progress = findViewById(R.id.progress)
|
|
errorBox = findViewById(R.id.error_box)
|
|
errorText = findViewById(R.id.error_text)
|
|
findViewById<Button>(R.id.error_retry).setOnClickListener {
|
|
errorBox.visibility = View.GONE
|
|
webView.reload()
|
|
}
|
|
|
|
// Keep the page between the status/navigation bars and above the IME.
|
|
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.root)) { view, insets ->
|
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
|
|
view.updatePadding(
|
|
left = bars.left,
|
|
top = bars.top,
|
|
right = bars.right,
|
|
bottom = maxOf(bars.bottom, ime.bottom),
|
|
)
|
|
WindowInsetsCompat.CONSUMED
|
|
}
|
|
|
|
with(webView.settings) {
|
|
javaScriptEnabled = true
|
|
domStorageEnabled = true
|
|
databaseEnabled = true
|
|
mediaPlaybackRequiresUserGesture = false
|
|
allowFileAccess = false
|
|
allowContentAccess = false
|
|
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
|
userAgentString = userAgentString + UA_SUFFIX
|
|
setSupportMultipleWindows(false)
|
|
}
|
|
CookieManager.getInstance().setAcceptCookie(true)
|
|
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, false)
|
|
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
|
|
webView.addJavascriptInterface(AndroidBridge(), "NikoGPTAndroid")
|
|
|
|
webView.webViewClient = object : WebViewClient() {
|
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
|
val url = request.url
|
|
if (url.scheme == "nikogpt" && url.host == "auth") {
|
|
// Deep link that reached the WebView instead of the browser.
|
|
handleAuthUri(url)
|
|
return true
|
|
}
|
|
if (url.host == HOST) return false
|
|
openExternally(url)
|
|
return true
|
|
}
|
|
|
|
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
|
if (request.isForMainFrame) {
|
|
errorText.text = getString(R.string.load_error, error.description)
|
|
errorBox.visibility = View.VISIBLE
|
|
}
|
|
}
|
|
|
|
override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
|
|
// Never bypass TLS problems; show the error instead.
|
|
handler.cancel()
|
|
errorText.text = getString(R.string.load_error, error.url ?: "")
|
|
errorBox.visibility = View.VISIBLE
|
|
}
|
|
|
|
override fun onPageFinished(view: WebView, url: String) {
|
|
refresh.isRefreshing = false
|
|
progress.visibility = View.GONE
|
|
// Persist cookies (the session) right after the page settles.
|
|
CookieManager.getInstance().flush()
|
|
}
|
|
}
|
|
|
|
webView.webChromeClient = object : WebChromeClient() {
|
|
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
|
progress.visibility = if (newProgress in 1..99) View.VISIBLE else View.GONE
|
|
progress.progress = newProgress
|
|
}
|
|
|
|
override fun onShowFileChooser(
|
|
view: WebView,
|
|
callback: ValueCallback<Array<Uri>>,
|
|
params: WebChromeClient.FileChooserParams,
|
|
): Boolean {
|
|
fileCallback?.onReceiveValue(null)
|
|
fileCallback = callback
|
|
return try {
|
|
filePicker.launch(arrayOf("*/*"))
|
|
true
|
|
} catch (e: ActivityNotFoundException) {
|
|
fileCallback = null
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
webView.setDownloadListener(DownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
|
|
download(url, userAgent, contentDisposition, mimeType)
|
|
})
|
|
|
|
refresh.setOnRefreshListener { webView.reload() }
|
|
|
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
|
override fun handleOnBackPressed() {
|
|
if (webView.canGoBack()) webView.goBack() else finish()
|
|
}
|
|
})
|
|
|
|
if (savedInstanceState == null) {
|
|
webView.loadUrl(START_URL)
|
|
} else {
|
|
webView.restoreState(savedInstanceState)
|
|
}
|
|
handleAuthIntent(intent)
|
|
}
|
|
|
|
// handleAuthIntent catches the nikogpt://auth?code=… deep link from the
|
|
// browser and completes the sign-in inside the app's WebView.
|
|
private fun handleAuthIntent(intent: Intent?) {
|
|
val data = intent?.data ?: return
|
|
if (data.scheme == "nikogpt" && data.host == "auth") {
|
|
handleAuthUri(data)
|
|
}
|
|
}
|
|
|
|
// AndroidBridge is exposed to the web app as window.NikoGPTAndroid: the
|
|
// Google buttons call it and the app opens the native credential picker.
|
|
private inner class AndroidBridge {
|
|
@JavascriptInterface
|
|
fun signInGoogle(clientId: String) {
|
|
runOnUiThread { openGooglePicker(clientId, link = false) }
|
|
}
|
|
|
|
@JavascriptInterface
|
|
fun linkGoogle(clientId: String) {
|
|
runOnUiThread { openGooglePicker(clientId, link = true) }
|
|
}
|
|
}
|
|
|
|
// openGooglePicker requests an ID token from Google Play services
|
|
// (Credential Manager). Without a usable provider — e.g. microG, no
|
|
// accounts, user cancelled — it falls back to the browser flow with the
|
|
// nikogpt:// hand-back.
|
|
private fun openGooglePicker(clientId: String, link: Boolean) {
|
|
if (clientId.isBlank()) {
|
|
openGoogleInBrowser(link)
|
|
return
|
|
}
|
|
val option = GetGoogleIdOption.Builder()
|
|
.setFilterByAuthorizedAccounts(false)
|
|
.setServerClientId(clientId)
|
|
.setAutoSelectEnabled(false)
|
|
.build()
|
|
val request = GetCredentialRequest.Builder()
|
|
.addCredentialOption(option)
|
|
.build()
|
|
lifecycleScope.launch {
|
|
try {
|
|
val response = CredentialManager.create(this@MainActivity)
|
|
.getCredential(this@MainActivity, request)
|
|
val idToken = GoogleIdTokenCredential.createFrom(response.credential.data).idToken
|
|
deliverToken(idToken, link)
|
|
} catch (e: GetCredentialException) {
|
|
openGoogleInBrowser(link)
|
|
} catch (e: Exception) {
|
|
openGoogleInBrowser(link)
|
|
}
|
|
}
|
|
}
|
|
|
|
// deliverToken hands the ID token back to the page, which signs in
|
|
// through the API inside this WebView (so the cookie stays in the app).
|
|
private fun deliverToken(idToken: String, link: Boolean) {
|
|
val fn = if (link) "nikoGoogleLinkToken" else "nikoGoogleToken"
|
|
val js = "window.$fn && window.$fn(" + org.json.JSONObject.quote(idToken) + ")"
|
|
webView.evaluateJavascript(js, null)
|
|
}
|
|
|
|
private fun openGoogleInBrowser(link: Boolean) {
|
|
val query = buildString {
|
|
if (link) append("link=1&")
|
|
append("app=1")
|
|
}
|
|
webView.loadUrl("https://$HOST/api/auth/google?$query")
|
|
}
|
|
|
|
private fun handleAuthUri(uri: Uri) {
|
|
val code = uri.getQueryParameter("code")
|
|
if (!code.isNullOrBlank()) {
|
|
webView.loadUrl("https://$HOST/api/auth/app?code=" + Uri.encode(code))
|
|
refresh.isRefreshing = true
|
|
return
|
|
}
|
|
val error = uri.getQueryParameter("error")
|
|
if (!error.isNullOrBlank()) {
|
|
Toast.makeText(this, getString(R.string.google_error, error), Toast.LENGTH_LONG).show()
|
|
}
|
|
}
|
|
|
|
override fun onNewIntent(intent: Intent) {
|
|
super.onNewIntent(intent)
|
|
setIntent(intent)
|
|
handleAuthIntent(intent)
|
|
}
|
|
|
|
private fun openExternally(uri: Uri) {
|
|
try {
|
|
startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
|
} catch (e: ActivityNotFoundException) {
|
|
Toast.makeText(this, R.string.no_app, Toast.LENGTH_SHORT).show()
|
|
}
|
|
}
|
|
|
|
private fun download(url: String, userAgent: String?, contentDisposition: String?, mimeType: String?) {
|
|
try {
|
|
val request = DownloadManager.Request(Uri.parse(url)).apply {
|
|
setMimeType(mimeType)
|
|
addRequestHeader("User-Agent", userAgent)
|
|
CookieManager.getInstance().getCookie(url)?.let { addRequestHeader("Cookie", it) }
|
|
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
|
setDestinationInExternalPublicDir(
|
|
Environment.DIRECTORY_DOWNLOADS,
|
|
URLUtil.guessFileName(url, contentDisposition, mimeType),
|
|
)
|
|
}
|
|
(getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager).enqueue(request)
|
|
Toast.makeText(this, R.string.download_started, Toast.LENGTH_SHORT).show()
|
|
} catch (e: Exception) {
|
|
Toast.makeText(this, R.string.download_failed, Toast.LENGTH_SHORT).show()
|
|
}
|
|
}
|
|
|
|
override fun onStop() {
|
|
super.onStop()
|
|
// Chromium writes cookies in batches: without an explicit flush the
|
|
// session cookie can be lost when Android kills the process, so the
|
|
// user would have to log in again after every restart.
|
|
CookieManager.getInstance().flush()
|
|
}
|
|
|
|
override fun onSaveInstanceState(outState: Bundle) {
|
|
super.onSaveInstanceState(outState)
|
|
webView.saveState(outState)
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
fileCallback?.onReceiveValue(null)
|
|
fileCallback = null
|
|
webView.destroy()
|
|
super.onDestroy()
|
|
}
|
|
}
|