nikogpt_android/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt
Niko Marmeladkov 898bcb792b
Some checks failed
build / apk (push) Has been cancelled
1.1.0: release signing, App Links, camera and share, update check
- signed release builds via keystore.properties (gitignored)
- Android App Links (https://chat.n1ko.dev/app-auth) verified through
  /.well-known/assetlinks.json; nikogpt:// stays as fallback
- attach sheet with File/Camera (FileProvider)
- Share to NikoGPT (ACTION_SEND text)
- user agent carries the app version so the web UI can offer updates
2026-10-05 18:14:21 +03:00

449 lines
18 KiB
Kotlin

package dev.n1ko.nikogpt
import android.annotation.SuppressLint
import android.app.AlertDialog
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.content.FileProvider
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.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.api.ApiException
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import java.io.File
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"
// The web app reads this marker to switch to the app handoff flow.
private val UA_SUFFIX = " NikoGPT-Android/" + BuildConfig.VERSION_NAME
}
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
// Shared text ("Share to NikoGPT") waiting for the page to load.
private var pendingShare: String? = null
// Where the camera writes the picture before it reaches the web app.
private var pendingCameraUri: Uri? = null
private var fileCallback: ValueCallback<Array<Uri>>? = null
private val filePicker: ActivityResultLauncher<Array<String>> =
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
fileCallback?.onReceiveValue(uris?.toTypedArray() ?: arrayOf())
fileCallback = null
}
// microG path: the legacy Google Sign-In API, which microG implements.
private var legacyLinkMode = false
private val legacySignIn: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val link = legacyLinkMode
try {
val account = GoogleSignIn.getSignedInAccountFromIntent(result.data)
.getResult(ApiException::class.java)
val token = account?.idToken
if (!token.isNullOrBlank()) deliverToken(token, link) else openGoogleInBrowser(link)
} catch (e: ApiException) {
openGoogleInBrowser(link)
} catch (e: Exception) {
openGoogleInBrowser(link)
}
}
// Camera capture for the attach button.
private val takePicture: ActivityResultLauncher<Uri> =
registerForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val cb = fileCallback
fileCallback = null
val uri = pendingCameraUri
pendingCameraUri = null
if (ok && uri != null) cb?.onReceiveValue(arrayOf(uri)) else cb?.onReceiveValue(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 (isAuthUrl(url)) {
// 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()
maybeDeliverShare()
}
}
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
AlertDialog.Builder(this@MainActivity)
.setItems(arrayOf(getString(R.string.attach_file), getString(R.string.attach_camera))) { _, which ->
if (which == 0) {
try {
filePicker.launch(arrayOf("*/*"))
} catch (e: ActivityNotFoundException) {
fileCallback?.onReceiveValue(null)
fileCallback = null
}
} else {
openCamera()
}
}
.setOnCancelListener {
fileCallback?.onReceiveValue(null)
fileCallback = null
}
.show()
return true
}
}
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)
handleShareIntent(intent)
}
// isAuthUrl matches both the nikogpt:// scheme and the verified App Link.
private fun isAuthUrl(uri: Uri): Boolean {
if (uri.scheme == "nikogpt" && uri.host == "auth") return true
return uri.scheme == "https" && uri.host == HOST && uri.path == "/app-auth"
}
// openCamera writes the shot into the cache and hands it to the web app.
private fun openCamera() {
try {
val dir = File(cacheDir, "camera").apply { mkdirs() }
val file = File(dir, "photo_" + System.currentTimeMillis() + ".jpg")
val uri = FileProvider.getUriForFile(this, "dev.n1ko.nikogpt.files", file)
pendingCameraUri = uri
takePicture.launch(uri)
} catch (e: Exception) {
fileCallback?.onReceiveValue(null)
fileCallback = null
}
}
// handleShareIntent catches "Share to NikoGPT" from other apps.
private fun handleShareIntent(intent: Intent?) {
if (intent == null || intent.action != Intent.ACTION_SEND) return
if (intent.type?.startsWith("text/") != true) return
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (!text.isNullOrBlank()) {
pendingShare = text
maybeDeliverShare()
}
}
// maybeDeliverShare fills the composer once the page is ready.
private fun maybeDeliverShare() {
val text = pendingShare ?: return
if (webView.url == null) return
pendingShare = null
webView.evaluateJavascript("window.nikoShare && window.nikoShare(" + org.json.JSONObject.quote(text) + ")", null)
}
// handleAuthIntent catches the auth deep link (nikogpt:// or App Link) and
// completes the sign-in inside the app's WebView.
private fun handleAuthIntent(intent: Intent?) {
val data = intent?.data ?: return
if (isAuthUrl(data)) {
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 tries the native pickers in order: Credential Manager
// (modern GMS) → legacy Google Sign-In (microG) → browser flow.
private fun openGooglePicker(clientId: String, link: Boolean) {
if (clientId.isBlank() || !googleServicesAvailable()) {
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: Exception) {
// No Credential Manager provider (microG) — try the legacy API.
openLegacyGooglePicker(clientId, link)
}
}
}
// googleServicesAvailable is true for GMS and for microG (which reports
// itself as Play services when signature spoofing is enabled).
private fun googleServicesAvailable(): Boolean {
return try {
GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
} catch (e: Exception) {
false
}
}
@Deprecated("microG only implements the legacy Google Sign-In API")
private fun openLegacyGooglePicker(clientId: String, link: Boolean) {
legacyLinkMode = link
try {
val options = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(clientId)
.requestEmail()
.build()
legacySignIn.launch(GoogleSignIn.getClient(this, options).signInIntent)
} 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)
handleShareIntent(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()
}
}