diff --git a/android-client/.gitignore b/android-client/.gitignore new file mode 100644 index 0000000..413f622 --- /dev/null +++ b/android-client/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +app/build/ +local.properties +*.iml +.idea/ +captures/ +.externalNativeBuild/ +.cxx/ diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts new file mode 100644 index 0000000..7218742 --- /dev/null +++ b/android-client/app/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.navi.client" + compileSdk = 35 + + defaultConfig { + applicationId = "com.navi.client" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.activity) +} diff --git a/android-client/app/proguard-rules.pro b/android-client/app/proguard-rules.pro new file mode 100644 index 0000000..22becfe --- /dev/null +++ b/android-client/app/proguard-rules.pro @@ -0,0 +1,3 @@ +-keep class com.navi.client.** { *; } +-keepattributes *Annotation* +-keepattributes SourceFile,LineNumberTable diff --git a/android-client/app/src/main/AndroidManifest.xml b/android-client/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1e911b4 --- /dev/null +++ b/android-client/app/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-client/app/src/main/java/com/navi/client/MainActivity.kt b/android-client/app/src/main/java/com/navi/client/MainActivity.kt new file mode 100644 index 0000000..fa12c55 --- /dev/null +++ b/android-client/app/src/main/java/com/navi/client/MainActivity.kt @@ -0,0 +1,180 @@ +package com.navi.client + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.MediaStore +import android.view.KeyEvent +import android.webkit.* +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +class MainActivity : AppCompatActivity() { + + private lateinit var webView: WebView + private var fileChooserCallback: ValueCallback>? = null + private var cameraImageUri: Uri? = null + private var pendingFileChooserCallback: ValueCallback>? = null + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { _ -> + pendingFileChooserCallback?.let { cb -> + pendingFileChooserCallback = null + openFileChooser(cb) + } + } + + private val filePickerLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + val uris: Array? = if (result.resultCode == Activity.RESULT_OK) { + val data = result.data + when { + data == null || (data.data == null && data.clipData == null) -> + cameraImageUri?.let { arrayOf(it) } + data.clipData != null -> { + val clip = data.clipData!! + Array(clip.itemCount) { clip.getItemAt(it).uri } + } + else -> data.data?.let { arrayOf(it) } + } + } else null + + fileChooserCallback?.onReceiveValue(uris) + fileChooserCallback = null + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val prefs = getSharedPreferences("navi", Context.MODE_PRIVATE) + val serverUrl = prefs.getString("server_url", null) + + if (serverUrl.isNullOrBlank()) { + startActivity(Intent(this, SetupActivity::class.java)) + finish() + return + } + + setContentView(R.layout.activity_main) + webView = findViewById(R.id.webview) + setupWebView(prefs) + webView.loadUrl(serverUrl) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView(prefs: android.content.SharedPreferences) { + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + cacheMode = WebSettings.LOAD_NO_CACHE + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + mediaPlaybackRequiresUserGesture = false + allowFileAccess = true + allowContentAccess = true + } + + webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false + + override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { + if (request.isForMainFrame) { + // Clear saved URL so next launch shows the setup screen + prefs.edit().remove("server_url").apply() + view.loadData(errorPage(error.description.toString()), "text/html", "utf-8") + } + } + } + + webView.webChromeClient = object : WebChromeClient() { + override fun onShowFileChooser( + view: WebView, + callback: ValueCallback>, + params: FileChooserParams + ): Boolean { + fileChooserCallback?.onReceiveValue(null) + fileChooserCallback = callback + + val needed = mutableListOf().apply { + if (ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED) add(Manifest.permission.CAMERA) + + val storage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + Manifest.permission.READ_MEDIA_IMAGES + else + Manifest.permission.READ_EXTERNAL_STORAGE + + if (ContextCompat.checkSelfPermission(this@MainActivity, storage) + != PackageManager.PERMISSION_GRANTED) add(storage) + } + + if (needed.isNotEmpty()) { + pendingFileChooserCallback = callback + fileChooserCallback = null + permissionLauncher.launch(needed.toTypedArray()) + } else { + openFileChooser(callback) + } + return true + } + } + } + + private fun openFileChooser(callback: ValueCallback>) { + fileChooserCallback = callback + + val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { + val photoFile = createTempImageFile() + cameraImageUri = FileProvider.getUriForFile(this, "$packageName.fileprovider", photoFile) + it.putExtra(MediaStore.EXTRA_OUTPUT, cameraImageUri) + } + + val galleryIntent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*", "application/pdf", "*/*")) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + } + + filePickerLauncher.launch( + Intent.createChooser(galleryIntent, getString(R.string.chooser_title)).apply { + putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(cameraIntent)) + } + ) + } + + private fun createTempImageFile(): File { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val dir = getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File.createTempFile("IMG_${timestamp}_", ".jpg", dir) + } + + private fun errorPage(message: String) = """ + +

Не удалось подключиться

+

$message

+

При следующем запуске будет запрошен адрес сервера

+ + """.trimIndent() + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) { + webView.goBack() + return true + } + return super.onKeyDown(keyCode, event) + } +} diff --git a/android-client/app/src/main/java/com/navi/client/SetupActivity.kt b/android-client/app/src/main/java/com/navi/client/SetupActivity.kt new file mode 100644 index 0000000..8634cbb --- /dev/null +++ b/android-client/app/src/main/java/com/navi/client/SetupActivity.kt @@ -0,0 +1,40 @@ +package com.navi.client + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Button +import android.widget.EditText +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity + +class SetupActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_setup) + + val prefs = getSharedPreferences("navi", Context.MODE_PRIVATE) + val urlField = findViewById(R.id.et_server_url) + val saveButton = findViewById