nikogpt_android/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt
Niko Marmeladkov 147b17dc44
Some checks failed
build / apk (push) Has been cancelled
Native Google sign-in via Credential Manager
- 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
2026-10-05 17:15:51 +03:00

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()
}
}