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 @@ + + + +