1.1.0: release signing, App Links, camera and share, update check
Some checks are pending
build / apk (push) Waiting to run

- 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
This commit is contained in:
Niko Marmeladkov 2026-10-05 18:14:21 +03:00
parent b2914f685a
commit 898bcb792b
8 changed files with 160 additions and 21 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
.gradle/
build/
local.properties
keystore.properties
*.iml
.idea/
.DS_Store

View file

@ -41,16 +41,27 @@ Or just open the project in Android Studio.
### Release signing
Create a keystore and export it as Gradle properties or use
`apksigner` on the release output:
The published APKs are signed with the project's release keystore
(`keystore.properties` + the `.jks` file, both outside git). To build them
yourself, create a keystore and point `keystore.properties` at it:
```bash
keytool -genkeypair -v -keystore nikogpt.jks -alias nikogpt \
-keyalg RSA -keysize 2048 -validity 10000
apksigner sign --ks nikogpt.jks --out nikogpt-release.apk \
app/build/outputs/apk/release/app-release-unsigned.apk
cat > keystore.properties <<EOF
storeFile=../nikogpt.jks
storePassword=...
keyAlias=nikogpt
keyPassword=...
EOF
./gradlew assembleRelease
```
**Installing over a debug-signed build requires uninstalling it first** (the
signatures differ). Add the release certificate's SHA-1 to the Android OAuth
client (Google Cloud) and its SHA-256 to `web.android_cert_sha256` in the bot
config for App Links.
## Google sign-in
Native "Continue with Google" is attempted in this order:

View file

@ -1,8 +1,19 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
// Release signing: keystore.properties (gitignored) points at the keystore.
// Without it, release builds stay unsigned and CI can sign them itself.
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
val hasKeystore = keystorePropertiesFile.exists()
if (hasKeystore) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
android {
namespace = "dev.n1ko.nikogpt"
compileSdk = 35
@ -11,8 +22,19 @@ android {
applicationId = "dev.n1ko.nikogpt"
minSdk = 29 // Android 10
targetSdk = 35
versionCode = 4
versionName = "1.0.4"
versionCode = 5
versionName = "1.1.0"
}
signingConfigs {
if (hasKeystore) {
create("release") {
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
@ -23,6 +45,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
if (hasKeystore) {
signingConfig = signingConfigs.getByName("release")
}
}
}

View file

@ -22,15 +22,36 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Google sign-in handoff: after the browser finishes OAuth the
server redirects to nikogpt://auth?code=… and the app
exchanges the one-time code inside its own WebView. -->
<!-- Google sign-in handoff (custom scheme fallback). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nikogpt" android:host="auth" />
</intent-filter>
<!-- Android App Link: verified by /.well-known/assetlinks.json. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="chat.n1ko.dev" android:pathPrefix="/app-auth" />
</intent-filter>
<!-- "Share to NikoGPT" from other apps. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="dev.n1ko.nikogpt.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -1,6 +1,7 @@
package dev.n1ko.nikogpt
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Context
@ -31,6 +32,7 @@ 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
@ -47,6 +49,7 @@ 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
/**
@ -61,7 +64,8 @@ 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"
// 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
@ -70,6 +74,11 @@ class MainActivity : AppCompatActivity() {
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 ->
@ -94,6 +103,16 @@ class MainActivity : AppCompatActivity() {
}
}
// 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)
@ -142,7 +161,7 @@ class MainActivity : AppCompatActivity() {
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url
if (url.scheme == "nikogpt" && url.host == "auth") {
if (isAuthUrl(url)) {
// Deep link that reached the WebView instead of the browser.
handleAuthUri(url)
return true
@ -171,6 +190,7 @@ class MainActivity : AppCompatActivity() {
progress.visibility = View.GONE
// Persist cookies (the session) right after the page settles.
CookieManager.getInstance().flush()
maybeDeliverShare()
}
}
@ -187,13 +207,25 @@ class MainActivity : AppCompatActivity() {
): Boolean {
fileCallback?.onReceiveValue(null)
fileCallback = callback
return try {
filePicker.launch(arrayOf("*/*"))
true
} catch (e: ActivityNotFoundException) {
fileCallback = null
false
}
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
}
}
@ -215,13 +247,53 @@ class MainActivity : AppCompatActivity() {
webView.restoreState(savedInstanceState)
}
handleAuthIntent(intent)
handleShareIntent(intent)
}
// handleAuthIntent catches the nikogpt://auth?code=… deep link from the
// browser and completes the sign-in inside the app's WebView.
// 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 (data.scheme == "nikogpt" && data.host == "auth") {
if (isAuthUrl(data)) {
handleAuthUri(data)
}
}
@ -325,6 +397,7 @@ class MainActivity : AppCompatActivity() {
super.onNewIntent(intent)
setIntent(intent)
handleAuthIntent(intent)
handleShareIntent(intent)
}
private fun openExternally(uri: Uri) {

View file

@ -7,4 +7,6 @@
<string name="download_started">Загрузка началась</string>
<string name="download_failed">Не удалось начать загрузку</string>
<string name="google_error">Не удалось войти через Google (%1$s)</string>
<string name="attach_file">Файл</string>
<string name="attach_camera">Камера</string>
</resources>

View file

@ -7,4 +7,6 @@
<string name="download_started">Download started</string>
<string name="download_failed">Could not start the download</string>
<string name="google_error">Google sign-in failed (%1$s)</string>
<string name="attach_file">File</string>
<string name="attach_camera">Camera</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="camera" path="camera/" />
</paths>