Newer
Older
navi-1 / android-client / app / src / main / java / com / navi / client / MainActivity.kt
@Eugene Sukhodolskiy Eugene Sukhodolskiy on 30 Apr 9 KB Open artifact links externally on Android
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.os.Message
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 serverHost: String = ""
    private var fileChooserCallback: ValueCallback<Array<Uri>>? = null
    private var cameraImageUri: Uri? = null
    private var pendingFileChooserCallback: ValueCallback<Array<Uri>>? = null

    private val permissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { _ ->
        pendingFileChooserCallback?.let { cb ->
            pendingFileChooserCallback = null
            openFileChooser(cb)
        }
    }

    private val filePickerLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        val uris: Array<Uri>? = 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
        }

        serverHost = Uri.parse(serverUrl).host ?: ""
        setContentView(R.layout.activity_main)
        WebView.setWebContentsDebuggingEnabled(true)
        webView = findViewById(R.id.webview)
        setupWebView(prefs)
        webView.loadUrl(serverUrl)
    }

    @SuppressLint("SetJavaScriptEnabled")
    private fun setupWebView(prefs: android.content.SharedPreferences) {
        webView.settings.apply {
            javaScriptEnabled = true
            javaScriptCanOpenWindowsAutomatically = true
            domStorageEnabled = true
            cacheMode = WebSettings.LOAD_NO_CACHE
            mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
            mediaPlaybackRequiresUserGesture = false
            allowFileAccess = true
            allowContentAccess = true
            setSupportMultipleWindows(true)
            userAgentString = "$userAgentString NaviAndroid/1.0"
        }

        webView.webViewClient = object : WebViewClient() {
            override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
                return if (shouldOpenExternally(request)) openExternal(request.url) else 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 onCreateWindow(
                view: WebView,
                isDialog: Boolean,
                isUserGesture: Boolean,
                resultMsg: Message
            ): Boolean {
                if (!isUserGesture) return false

                val popup = WebView(this@MainActivity)
                popup.webViewClient = object : WebViewClient() {
                    override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
                        openExternal(request.url)
                        view.destroy()
                        return true
                    }
                }

                val transport = resultMsg.obj as WebView.WebViewTransport
                transport.webView = popup
                resultMsg.sendToTarget()
                return true
            }

            override fun onShowFileChooser(
                view: WebView,
                callback: ValueCallback<Array<Uri>>,
                params: FileChooserParams
            ): Boolean {
                fileChooserCallback?.onReceiveValue(null)
                fileChooserCallback = callback

                val needed = mutableListOf<String>().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 shouldOpenExternally(request: WebResourceRequest): Boolean {
        val uri = request.url
        val scheme = uri.scheme?.lowercase(Locale.US) ?: return false

        if (scheme != "http" && scheme != "https") {
            return request.hasGesture()
        }

        val host = uri.host ?: return false
        if (host != serverHost) return true

        if (!request.hasGesture()) return false

        val path = uri.encodedPath ?: return false
        if (path.startsWith("/content-viewers/")) return true
        if (Regex("^/sessions/[^/]+/files/[^/]+").containsMatchIn(path)) return true

        return uri.getQueryParameter("download") == "1"
    }

    private fun openExternal(uri: Uri): Boolean {
        return try {
            startActivity(
                Intent(Intent.ACTION_VIEW, uri).apply {
                    addCategory(Intent.CATEGORY_BROWSABLE)
                }
            )
            true
        } catch (e: Exception) {
            false
        }
    }

    private fun openFileChooser(callback: ValueCallback<Array<Uri>>) {
        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) = """
        <html><body style="background:#111;color:#888;font-family:sans-serif;padding:40px;text-align:center">
        <h2 style="color:#555">Не удалось подключиться</h2>
        <p>$message</p>
        <p style="font-size:12px">При следующем запуске будет запрошен адрес сервера</p>
        </body></html>
    """.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)
    }
}