Some checks failed
build / apk (push) Has been cancelled
- 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
449 lines
18 KiB
Kotlin
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()
|
|
}
|
|
}
|