1.1.0: release signing, App Links, camera and share, update check
Some checks are pending
build / apk (push) Waiting to run
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:
parent
b2914f685a
commit
898bcb792b
8 changed files with 160 additions and 21 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
keystore.properties
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="camera" path="camera/" />
|
||||
</paths>
|
||||
Loading…
Add table
Reference in a new issue