diff --git a/.gitignore b/.gitignore
index 9d2fc3f..a3567a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.gradle/
build/
local.properties
+keystore.properties
*.iml
.idea/
.DS_Store
diff --git a/README.md b/README.md
index 691a241..4ef6cbb 100644
--- a/README.md
+++ b/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 <
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt b/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt
index 005c833..63d7c50 100644
--- a/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt
+++ b/app/src/main/java/dev/n1ko/nikogpt/MainActivity.kt
@@ -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>? = null
private val filePicker: ActivityResultLauncher> =
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
@@ -94,6 +103,16 @@ class MainActivity : AppCompatActivity() {
}
}
+ // Camera capture for the attach button.
+ private val takePicture: ActivityResultLauncher =
+ 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) {
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index f20ba8a..05c1c28 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -7,4 +7,6 @@
Загрузка началась
Не удалось начать загрузку
Не удалось войти через Google (%1$s)
+ Файл
+ Камера
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 498fa8d..8287ede 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -7,4 +7,6 @@
Download started
Could not start the download
Google sign-in failed (%1$s)
+ File
+ Camera
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..9ec9888
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+