Просмотр исходного кода

构建pda应用,采用shell+webview,处理鉴权,网络等功能

stoney дней назад: 5
Родитель
Сommit
b37906e9d8
32 измененных файлов с 1419 добавлено и 4 удалено
  1. 18 3
      .gitignore
  2. 115 1
      README.md
  3. 54 0
      app/build.gradle.kts
  4. 1 0
      app/proguard-rules.pro
  5. 45 0
      app/src/main/AndroidManifest.xml
  6. 562 0
      app/src/main/java/com/zjznai/safetycheck/MainActivity.kt
  7. 31 0
      app/src/main/java/com/zjznai/safetycheck/SplashActivity.kt
  8. BIN
      app/src/main/res/drawable-nodpi/app_logo.png
  9. 12 0
      app/src/main/res/drawable/ic_launcher_foreground.xml
  10. 12 0
      app/src/main/res/drawable/ic_launcher_monochrome.xml
  11. 93 0
      app/src/main/res/layout/activity_main.xml
  12. 53 0
      app/src/main/res/layout/activity_splash.xml
  13. 6 0
      app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  14. 6 0
      app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  15. 7 0
      app/src/main/res/mipmap-anydpi/ic_launcher.xml
  16. 7 0
      app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
  17. 13 0
      app/src/main/res/values-night/themes.xml
  18. 8 0
      app/src/main/res/values/colors.xml
  19. 3 0
      app/src/main/res/values/ic_launcher_background.xml
  20. 12 0
      app/src/main/res/values/strings.xml
  21. 16 0
      app/src/main/res/values/themes.xml
  22. 9 0
      app/src/main/res/xml/file_paths.xml
  23. 6 0
      app/src/main/res/xml/network_security_config.xml
  24. 4 0
      build.gradle.kts
  25. 4 0
      gradle.properties
  26. 19 0
      gradle/libs.versions.toml
  27. BIN
      gradle/wrapper/gradle-wrapper.jar
  28. 5 0
      gradle/wrapper/gradle-wrapper.properties
  29. 185 0
      gradlew
  30. 89 0
      gradlew.bat
  31. BIN
      safetychecklogopda.png
  32. 24 0
      settings.gradle.kts

+ 18 - 3
.gitignore

@@ -1,4 +1,4 @@
-# ---> macOS
+# ---> macOS
 .DS_Store
 .AppleDouble
 .LSOverride
@@ -6,7 +6,6 @@
 # Icon must end with two \r
 Icon
 
-
 # Thumbnails
 ._*
 
@@ -24,4 +23,20 @@ Icon
 Network Trash Folder
 Temporary Items
 .apdisk
-
+
+# ---> Android / Gradle
+.gradle/
+.kotlin/
+local.properties
+**/build/
+**/out/
+.cxx/
+.externalNativeBuild/
+.idea/
+*.iml
+captures/
+*.apk
+*.aab
+
+# ---> Claude Code
+.claude/

+ 115 - 1
README.md

@@ -1,3 +1,117 @@
 # appForPDA
 
-安卓手持巡检仪App,采用hybrid shell+webview形式
+安卓手持巡检仪 App,为北京安科院设计,采用 **原生 Android 壳 + WebView** 形式。
+
+## 项目简介
+
+当前仓库已经是一个可直接使用 **Android Studio** 打开的完整 Android 工程。
+
+应用基础信息:
+
+- 应用名称:`安全检查`
+- 包名:`com.zjznai.safetycheck`
+- 最低支持版本:Android 7.0(`minSdk 24`)
+- 目标版本:Android 15(`targetSdk 35`)
+- WebView 启动地址:`http://192.168.1.8/h5/#/pages/views/login/login`
+
+## 技术方案
+
+- Kotlin
+- Gradle Kotlin DSL
+- AndroidX
+- Material 3
+- 单 Activity WebView 壳结构
+- 独立 `SplashActivity` 启动页
+- ViewBinding
+
+## 已实现功能
+
+### 1. 启动页(Launch Page)
+- 应用启动先进入白底 launch 页面
+- 中间展示 app logo
+- 底部展示版权与技术支持信息
+- 启动页结束后自动进入主页面
+
+### 2. WebView 壳能力
+- 启动后自动加载指定 H5 地址
+- 隐藏 WebView 垂直/水平滚动条
+- 返回键优先回退网页历史
+- `tel:`、`mailto:` 等外部 scheme 交由系统处理
+- 支持沉浸式全屏显示
+
+### 3. 网络与离线态处理
+- 仅对白名单地址启用 HTTP 明文访问
+- 主页面加载失败时拦截 WebView 默认错误页
+- 不暴露默认错误页中的访问链接
+- 区分:
+  - 无网络连接
+  - 服务暂时不可用
+- 提供原生风格的全屏异常状态页
+- 网络恢复后自动重新加载页面
+
+### 4. H5 权限能力桥接
+- 支持 H5 请求摄像头权限
+- 支持 H5 请求麦克风权限(生产 HTTPS 环境下可正常走 WebView 权限链路)
+- 支持 H5 文件上传/相册选择
+- 支持 H5 图片拍照上传
+- 通过 `FileProvider` 安全回传拍摄结果
+
+### 5. UI 与品牌
+- 应用名称为“安全检查”
+- 应用图标使用仓库内提供的 `safetychecklogopda.png`
+- 当前主题主色:`#0183FA`
+
+## 构建与运行
+
+### 使用 Android Studio
+1. 使用 Android Studio 打开仓库根目录。
+2. 等待 Gradle Sync 完成。
+3. 连接真机或启动模拟器。
+4. 运行 `app` 模块。
+
+### 命令行构建
+在仓库根目录执行:
+
+```bash
+./gradlew assembleDebug
+```
+
+生成的调试包路径:
+
+```text
+app/build/outputs/apk/debug/app-debug.apk
+```
+
+## 权限说明
+
+当前应用声明了以下权限:
+
+- `INTERNET`
+- `ACCESS_NETWORK_STATE`
+- `CAMERA`
+- `RECORD_AUDIO`
+
+说明:
+- `CAMERA` 用于 H5 拍照、摄像头调用
+- `RECORD_AUDIO` 用于 H5 麦克风调用
+- `ACCESS_NETWORK_STATE` 用于区分网络异常状态并在网络恢复后自动重试
+
+## 开发说明
+
+### 关于 HTTP 与 HTTPS
+当前测试地址使用的是局域网 HTTP:
+
+```text
+http://192.168.1.8/h5/#/pages/views/login/login
+```
+
+因此项目中配置了受控的明文访问能力用于测试环境。
+
+生产环境如果切换为 HTTPS,WebView 对摄像头/麦克风等 Web 能力的支持会更完整、更符合浏览器安全策略。
+
+## 当前适用场景
+
+本项目适合用于:
+- Android 手持终端/PDA 场景
+- 原有 H5 系统的原生壳封装
+- 需要摄像头、麦克风、文件上传桥接能力的 WebView 应用

+ 54 - 0
app/build.gradle.kts

@@ -0,0 +1,54 @@
+plugins {
+    alias(libs.plugins.android.application)
+    alias(libs.plugins.jetbrains.kotlin.android)
+}
+
+android {
+    namespace = "com.zjznai.safetycheck"
+    compileSdk = 35
+
+    defaultConfig {
+        applicationId = "com.zjznai.safetycheck"
+        minSdk = 24
+        targetSdk = 35
+        versionCode = 1
+        versionName = "1.0"
+
+        buildConfigField("String", "START_URL", "\"http://192.168.1.8/h5/#/pages/views/login/login\"")
+        vectorDrawables {
+            useSupportLibrary = true
+        }
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_17.toString()
+    }
+
+    buildFeatures {
+        buildConfig = true
+        viewBinding = true
+    }
+}
+
+dependencies {
+    implementation(libs.androidx.core.ktx)
+    implementation(libs.androidx.appcompat)
+    implementation(libs.google.material)
+    implementation(libs.androidx.activity)
+    implementation(libs.androidx.constraintlayout)
+}

+ 1 - 0
app/proguard-rules.pro

@@ -0,0 +1 @@
+# Project-specific ProGuard rules.

+ 45 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.CAMERA" />
+    <uses-permission android:name="android.permission.RECORD_AUDIO" />
+
+    <application
+        android:allowBackup="true"
+        android:icon="@drawable/app_logo"
+        android:label="@string/app_name"
+        android:networkSecurityConfig="@xml/network_security_config"
+        android:roundIcon="@drawable/app_logo"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.SafetyCheck">
+        <provider
+            android:name="androidx.core.content.FileProvider"
+            android:authorities="${applicationId}.fileprovider"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/file_paths" />
+        </provider>
+
+        <activity
+            android:name=".SplashActivity"
+            android:exported="true"
+            android:theme="@style/Theme.SafetyCheck.Launch">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:launchMode="singleTask"
+            android:windowSoftInputMode="adjustResize" />
+    </application>
+
+</manifest>

+ 562 - 0
app/src/main/java/com/zjznai/safetycheck/MainActivity.kt

@@ -0,0 +1,562 @@
+package com.zjznai.safetycheck
+
+import android.Manifest
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.Uri
+import android.os.Bundle
+import android.os.Environment
+import android.provider.MediaStore
+import android.view.View
+import android.webkit.CookieManager
+import android.webkit.PermissionRequest
+import android.webkit.ValueCallback
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import com.zjznai.safetycheck.databinding.ActivityMainBinding
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class MainActivity : AppCompatActivity() {
+
+    private enum class ConnectionState {
+        NONE,
+        NO_NETWORK,
+        SERVER_UNREACHABLE,
+    }
+
+    private lateinit var binding: ActivityMainBinding
+    private val connectivityManager by lazy {
+        getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+    }
+
+    private var pendingPermissionRequest: PermissionRequest? = null
+    private var pendingFilePathCallback: ValueCallback<Array<Uri>>? = null
+    private var pendingFileChooserParams: WebChromeClient.FileChooserParams? = null
+    private var pendingCameraImageUri: Uri? = null
+    private var currentConnectionState = ConnectionState.NONE
+    private var isPageLoading = false
+    private var isNetworkCallbackRegistered = false
+
+    private val networkCallback = object : ConnectivityManager.NetworkCallback() {
+        override fun onAvailable(network: Network) {
+            runOnUiThread {
+                if (currentConnectionState != ConnectionState.NONE && !isPageLoading) {
+                    loadHomePage()
+                }
+            }
+        }
+    }
+
+    private val mediaPermissionLauncher = registerForActivityResult(
+        ActivityResultContracts.RequestMultiplePermissions()
+    ) { permissions ->
+        val request = pendingPermissionRequest ?: return@registerForActivityResult
+        pendingPermissionRequest = null
+
+        val grantedResources = request.resources.filter { resource ->
+            when (resource) {
+                PermissionRequest.RESOURCE_VIDEO_CAPTURE -> permissions[Manifest.permission.CAMERA] == true
+                PermissionRequest.RESOURCE_AUDIO_CAPTURE -> permissions[Manifest.permission.RECORD_AUDIO] == true
+                else -> false
+            }
+        }
+
+        if (grantedResources.isNotEmpty()) {
+            request.grant(grantedResources.toTypedArray())
+        } else {
+            request.deny()
+        }
+    }
+
+    private val cameraPermissionLauncher = registerForActivityResult(
+        ActivityResultContracts.RequestPermission()
+    ) { granted ->
+        val fileChooserParams = pendingFileChooserParams ?: return@registerForActivityResult
+        if (granted) {
+            launchFileChooser(fileChooserParams, includeCamera = true)
+        } else if (isCaptureOnlyImageRequest(fileChooserParams)) {
+            clearPendingFileChooser()
+        } else {
+            launchFileChooser(fileChooserParams, includeCamera = false)
+        }
+    }
+
+    private val fileChooserLauncher = registerForActivityResult(
+        ActivityResultContracts.StartActivityForResult()
+    ) { result ->
+        val resultUris = when {
+            result.resultCode != Activity.RESULT_OK -> null
+            result.data?.clipData != null -> {
+                val clipData = result.data!!.clipData!!
+                Array(clipData.itemCount) { index -> clipData.getItemAt(index).uri }
+            }
+            result.data?.data != null -> arrayOf(result.data!!.data!!)
+            pendingCameraImageUri != null -> arrayOf(pendingCameraImageUri!!)
+            else -> null
+        }
+
+        clearPendingFileChooser(resultUris)
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        binding = ActivityMainBinding.inflate(layoutInflater)
+        setContentView(binding.root)
+
+        enableImmersiveMode()
+        WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
+        configureWebView()
+        configureOfflineState()
+        handleBackPress()
+
+        if (savedInstanceState == null) {
+            loadHomePage()
+        } else {
+            binding.webView.restoreState(savedInstanceState)
+            currentConnectionState = savedInstanceState.getString(KEY_CONNECTION_STATE)
+                ?.let(ConnectionState::valueOf)
+                ?: ConnectionState.NONE
+            renderConnectionState(currentConnectionState)
+        }
+    }
+
+    override fun onStart() {
+        super.onStart()
+        registerNetworkCallback()
+    }
+
+    override fun onStop() {
+        unregisterNetworkCallback()
+        super.onStop()
+    }
+
+    private fun enableImmersiveMode() {
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+        WindowCompat.getInsetsController(window, window.decorView).apply {
+            systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+            hide(WindowInsetsCompat.Type.systemBars())
+        }
+    }
+
+    private fun configureOfflineState() {
+        binding.retryButton.setOnClickListener {
+            loadHomePage()
+        }
+    }
+
+    private fun configureWebView() {
+        CookieManager.getInstance().setAcceptCookie(true)
+        CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
+
+        binding.webView.apply {
+            isVerticalScrollBarEnabled = false
+            isHorizontalScrollBarEnabled = false
+            overScrollMode = WebView.OVER_SCROLL_NEVER
+            webChromeClient = object : WebChromeClient() {
+                override fun onPermissionRequest(request: PermissionRequest) {
+                    runOnUiThread {
+                        handleWebPermissionRequest(request)
+                    }
+                }
+
+                override fun onPermissionRequestCanceled(request: PermissionRequest) {
+                    if (pendingPermissionRequest == request) {
+                        pendingPermissionRequest = null
+                    }
+                }
+
+                override fun onShowFileChooser(
+                    webView: WebView?,
+                    filePathCallback: ValueCallback<Array<Uri>>?,
+                    fileChooserParams: FileChooserParams?
+                ): Boolean {
+                    if (filePathCallback == null || fileChooserParams == null) {
+                        return false
+                    }
+
+                    pendingFilePathCallback?.onReceiveValue(null)
+                    pendingFilePathCallback = filePathCallback
+                    pendingFileChooserParams = fileChooserParams
+
+                    if (shouldOfferCameraCapture(fileChooserParams) && !hasCameraPermission()) {
+                        cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+                    } else {
+                        launchFileChooser(
+                            fileChooserParams = fileChooserParams,
+                            includeCamera = shouldOfferCameraCapture(fileChooserParams)
+                        )
+                    }
+                    return true
+                }
+            }
+            webViewClient = object : WebViewClient() {
+                override fun shouldOverrideUrlLoading(
+                    view: WebView,
+                    request: WebResourceRequest
+                ): Boolean {
+                    val uri = request.url
+                    return if (uri.scheme == "http" || uri.scheme == "https") {
+                        false
+                    } else {
+                        openExternalUri(uri)
+                        true
+                    }
+                }
+
+                override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+                    if (url == BLANK_PAGE_URL && currentConnectionState != ConnectionState.NONE) {
+                        binding.loadingIndicator.hide()
+                        return
+                    }
+                    isPageLoading = true
+                    renderConnectionState(ConnectionState.NONE)
+                    binding.loadingIndicator.show()
+                }
+
+                override fun onReceivedError(
+                    view: WebView?,
+                    request: WebResourceRequest,
+                    error: WebResourceError?
+                ) {
+                    if (!request.isForMainFrame || request.url.toString() == BLANK_PAGE_URL) {
+                        return
+                    }
+                    isPageLoading = false
+                    clearWebViewContent(view)
+                    renderConnectionState(resolveConnectionState())
+                }
+
+                override fun onReceivedHttpError(
+                    view: WebView?,
+                    request: WebResourceRequest,
+                    errorResponse: WebResourceResponse
+                ) {
+                    if (!request.isForMainFrame || errorResponse.statusCode < 400) {
+                        return
+                    }
+                    isPageLoading = false
+                    clearWebViewContent(view)
+                    renderConnectionState(ConnectionState.SERVER_UNREACHABLE)
+                }
+
+                override fun onPageFinished(view: WebView?, url: String?) {
+                    if (url == BLANK_PAGE_URL && currentConnectionState != ConnectionState.NONE) {
+                        binding.loadingIndicator.hide()
+                        return
+                    }
+                    isPageLoading = false
+                    renderConnectionState(ConnectionState.NONE)
+                    binding.loadingIndicator.hide()
+                }
+            }
+
+            settings.apply {
+                javaScriptEnabled = true
+                domStorageEnabled = true
+                loadsImagesAutomatically = true
+                mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
+                mediaPlaybackRequiresUserGesture = false
+                builtInZoomControls = false
+                displayZoomControls = false
+                allowFileAccess = true
+                allowContentAccess = true
+                setSupportZoom(false)
+            }
+        }
+    }
+
+    private fun loadHomePage() {
+        if (!isNetworkAvailable()) {
+            isPageLoading = false
+            clearWebViewContent(binding.webView)
+            renderConnectionState(ConnectionState.NO_NETWORK)
+            return
+        }
+
+        isPageLoading = true
+        renderConnectionState(ConnectionState.NONE)
+        binding.loadingIndicator.show()
+        binding.webView.loadUrl(BuildConfig.START_URL)
+    }
+
+    private fun renderConnectionState(state: ConnectionState) {
+        currentConnectionState = state
+        val showOffline = state != ConnectionState.NONE
+
+        binding.offlineContainer.visibility = if (showOffline) View.VISIBLE else View.GONE
+        binding.webView.visibility = if (showOffline) View.INVISIBLE else View.VISIBLE
+
+        when (state) {
+            ConnectionState.NONE -> {
+                binding.offlineHint.visibility = View.GONE
+            }
+            ConnectionState.NO_NETWORK -> {
+                binding.offlineTitle.setText(R.string.offline_no_network_title)
+                binding.offlineMessage.setText(R.string.offline_no_network_message)
+                binding.offlineHint.setText(R.string.offline_auto_retry_hint)
+                binding.offlineHint.visibility = View.VISIBLE
+                binding.retryButton.setText(R.string.retry)
+                binding.loadingIndicator.hide()
+            }
+            ConnectionState.SERVER_UNREACHABLE -> {
+                binding.offlineTitle.setText(R.string.offline_server_title)
+                binding.offlineMessage.setText(R.string.offline_server_message)
+                binding.offlineHint.visibility = View.GONE
+                binding.retryButton.setText(R.string.retry)
+                binding.loadingIndicator.hide()
+            }
+        }
+    }
+
+    private fun resolveConnectionState(): ConnectionState {
+        return if (isNetworkAvailable()) {
+            ConnectionState.SERVER_UNREACHABLE
+        } else {
+            ConnectionState.NO_NETWORK
+        }
+    }
+
+    @Suppress("DEPRECATION")
+    private fun isNetworkAvailable(): Boolean {
+        return connectivityManager.activeNetworkInfo?.isConnected == true
+    }
+
+    private fun registerNetworkCallback() {
+        if (isNetworkCallbackRegistered) {
+            return
+        }
+        connectivityManager.registerDefaultNetworkCallback(networkCallback)
+        isNetworkCallbackRegistered = true
+    }
+
+    private fun unregisterNetworkCallback() {
+        if (!isNetworkCallbackRegistered) {
+            return
+        }
+        connectivityManager.unregisterNetworkCallback(networkCallback)
+        isNetworkCallbackRegistered = false
+    }
+
+    private fun clearWebViewContent(view: WebView?) {
+        view?.stopLoading()
+        if (view?.url != BLANK_PAGE_URL) {
+            view?.loadUrl(BLANK_PAGE_URL)
+        }
+    }
+
+    private fun handleWebPermissionRequest(request: PermissionRequest) {
+        pendingPermissionRequest?.deny()
+        pendingPermissionRequest = request
+
+        val permissionsToRequest = buildList {
+            if (request.resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
+                add(Manifest.permission.CAMERA)
+            }
+            if (request.resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
+                add(Manifest.permission.RECORD_AUDIO)
+            }
+        }.distinct()
+
+        if (permissionsToRequest.isEmpty()) {
+            request.deny()
+            pendingPermissionRequest = null
+            return
+        }
+
+        val missingPermissions = permissionsToRequest.filterNot { permission ->
+            ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
+        }
+
+        if (missingPermissions.isEmpty()) {
+            request.grant(request.resources)
+            pendingPermissionRequest = null
+        } else {
+            mediaPermissionLauncher.launch(missingPermissions.toTypedArray())
+        }
+    }
+
+    private fun launchFileChooser(
+        fileChooserParams: WebChromeClient.FileChooserParams,
+        includeCamera: Boolean
+    ) {
+        pendingFileChooserParams = null
+
+        val contentIntent = Intent(Intent.ACTION_GET_CONTENT).apply {
+            addCategory(Intent.CATEGORY_OPENABLE)
+            type = resolvePrimaryMimeType(fileChooserParams.acceptTypes)
+            val extraMimeTypes = resolveExtraMimeTypes(fileChooserParams.acceptTypes)
+            if (extraMimeTypes.isNotEmpty()) {
+                putExtra(Intent.EXTRA_MIME_TYPES, extraMimeTypes)
+            }
+            putExtra(
+                Intent.EXTRA_ALLOW_MULTIPLE,
+                fileChooserParams.mode == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE
+            )
+        }
+
+        val cameraIntent = if (includeCamera) createCameraCaptureIntent() else null
+        val launchIntent = if (isCaptureOnlyImageRequest(fileChooserParams) && cameraIntent != null) {
+            cameraIntent
+        } else {
+            Intent(Intent.ACTION_CHOOSER).apply {
+                putExtra(Intent.EXTRA_INTENT, contentIntent)
+                putExtra(Intent.EXTRA_TITLE, getString(R.string.file_chooser_title))
+                if (cameraIntent != null) {
+                    putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(cameraIntent))
+                }
+            }
+        }
+
+        try {
+            fileChooserLauncher.launch(launchIntent)
+        } catch (_: ActivityNotFoundException) {
+            clearPendingFileChooser()
+        }
+    }
+
+    private fun createCameraCaptureIntent(): Intent? {
+        val imageFile = createTempImageFile() ?: return null
+        val imageUri = FileProvider.getUriForFile(this, "$packageName.fileprovider", imageFile)
+        pendingCameraImageUri = imageUri
+
+        val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
+        if (intent.resolveActivity(packageManager) == null) {
+            pendingCameraImageUri = null
+            return null
+        }
+
+        val grantFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+        intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri)
+        intent.clipData = ClipData.newUri(contentResolver, "CapturedImage", imageUri)
+        intent.addFlags(grantFlags)
+
+        packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY).forEach { resolveInfo ->
+            grantUriPermission(resolveInfo.activityInfo.packageName, imageUri, grantFlags)
+        }
+
+        return intent
+    }
+
+    private fun createTempImageFile(): File? {
+        val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
+        val storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: cacheDir
+        return runCatching {
+            File.createTempFile("JPEG_${timeStamp}_", ".jpg", storageDir)
+        }.getOrNull()
+    }
+
+    private fun hasCameraPermission(): Boolean {
+        return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
+    }
+
+    private fun shouldOfferCameraCapture(fileChooserParams: WebChromeClient.FileChooserParams): Boolean {
+        return fileChooserParams.isCaptureEnabled || acceptsImages(fileChooserParams.acceptTypes)
+    }
+
+    private fun isCaptureOnlyImageRequest(fileChooserParams: WebChromeClient.FileChooserParams): Boolean {
+        return fileChooserParams.isCaptureEnabled && acceptsImages(fileChooserParams.acceptTypes)
+    }
+
+    private fun acceptsImages(acceptTypes: Array<String>): Boolean {
+        val types = normalizeAcceptTypes(acceptTypes)
+        return types.isEmpty() || types.any { it.startsWith("image/") || it == "*/*" }
+    }
+
+    private fun resolvePrimaryMimeType(acceptTypes: Array<String>): String {
+        val types = normalizeAcceptTypes(acceptTypes)
+        return when {
+            types.isEmpty() -> "*/*"
+            types.size == 1 -> types.first()
+            else -> "*/*"
+        }
+    }
+
+    private fun resolveExtraMimeTypes(acceptTypes: Array<String>): Array<String> {
+        val types = normalizeAcceptTypes(acceptTypes)
+        return if (types.size > 1) types.toTypedArray() else emptyArray()
+    }
+
+    private fun normalizeAcceptTypes(acceptTypes: Array<String>): List<String> {
+        return acceptTypes
+            .flatMap { it.split(',') }
+            .map { it.trim() }
+            .filter { it.isNotEmpty() }
+            .distinct()
+    }
+
+    private fun clearPendingFileChooser(resultUris: Array<Uri>? = null) {
+        pendingFilePathCallback?.onReceiveValue(resultUris)
+        pendingFilePathCallback = null
+        pendingFileChooserParams = null
+        pendingCameraImageUri = null
+    }
+
+    private fun handleBackPress() {
+        onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
+            override fun handleOnBackPressed() {
+                if (binding.webView.canGoBack()) {
+                    binding.webView.goBack()
+                } else {
+                    finish()
+                }
+            }
+        })
+    }
+
+    private fun openExternalUri(uri: Uri) {
+        val intent = Intent(Intent.ACTION_VIEW, uri)
+        try {
+            startActivity(intent)
+        } catch (_: ActivityNotFoundException) {
+        }
+    }
+
+    override fun onWindowFocusChanged(hasFocus: Boolean) {
+        super.onWindowFocusChanged(hasFocus)
+        if (hasFocus) {
+            enableImmersiveMode()
+        }
+    }
+
+    override fun onSaveInstanceState(outState: Bundle) {
+        outState.putString(KEY_CONNECTION_STATE, currentConnectionState.name)
+        binding.webView.saveState(outState)
+        super.onSaveInstanceState(outState)
+    }
+
+    override fun onDestroy() {
+        pendingPermissionRequest?.deny()
+        pendingPermissionRequest = null
+        clearPendingFileChooser()
+        binding.webView.destroy()
+        super.onDestroy()
+    }
+
+    companion object {
+        private const val BLANK_PAGE_URL = "about:blank"
+        private const val KEY_CONNECTION_STATE = "connection_state"
+    }
+}

+ 31 - 0
app/src/main/java/com/zjznai/safetycheck/SplashActivity.kt

@@ -0,0 +1,31 @@
+package com.zjznai.safetycheck
+
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import androidx.appcompat.app.AppCompatActivity
+
+class SplashActivity : AppCompatActivity() {
+
+    private val handler = Handler(Looper.getMainLooper())
+    private val navigateToMain = Runnable {
+        startActivity(Intent(this, MainActivity::class.java))
+        finish()
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.activity_splash)
+        handler.postDelayed(navigateToMain, SPLASH_DURATION_MS)
+    }
+
+    override fun onDestroy() {
+        handler.removeCallbacks(navigateToMain)
+        super.onDestroy()
+    }
+
+    companion object {
+        private const val SPLASH_DURATION_MS = 1200L
+    }
+}

BIN
app/src/main/res/drawable-nodpi/app_logo.png


+ 12 - 0
app/src/main/res/drawable/ic_launcher_foreground.xml

@@ -0,0 +1,12 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path
+        android:fillColor="#FFFFFF"
+        android:pathData="M54,12L24,24v24c0,20.45 12.8,39.58 30,48 17.2,-8.42 30,-27.55 30,-48V24L54,12z" />
+    <path
+        android:fillColor="#0F766E"
+        android:pathData="M45,58.5l-8.49,-8.49 -5.66,5.66L45,69.81 77.14,37.67 71.49,32 45,58.5z" />
+</vector>

+ 12 - 0
app/src/main/res/drawable/ic_launcher_monochrome.xml

@@ -0,0 +1,12 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path
+        android:fillColor="#000000"
+        android:pathData="M54,12L24,24v24c0,20.45 12.8,39.58 30,48 17.2,-8.42 30,-27.55 30,-48V24L54,12z" />
+    <path
+        android:fillColor="#000000"
+        android:pathData="M45,58.5l-8.49,-8.49 -5.66,5.66L45,69.81 77.14,37.67 71.49,32 45,58.5z" />
+</vector>

+ 93 - 0
app/src/main/res/layout/activity_main.xml

@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <WebView
+        android:id="@+id/webView"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <LinearLayout
+        android:id="@+id/offlineContainer"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:background="@color/md_theme_background"
+        android:gravity="center"
+        android:orientation="vertical"
+        android:padding="24dp"
+        android:visibility="gone"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent">
+
+        <ImageView
+            android:id="@+id/offlineIcon"
+            android:layout_width="120dp"
+            android:layout_height="120dp"
+            android:scaleType="centerInside"
+            android:src="@drawable/app_logo"
+            android:contentDescription="@string/app_name" />
+
+        <TextView
+            android:id="@+id/offlineTitle"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="24dp"
+            android:textColor="@color/md_theme_onBackground"
+            android:textSize="24sp"
+            android:textStyle="bold" />
+
+        <TextView
+            android:id="@+id/offlineMessage"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="12dp"
+            android:gravity="center"
+            android:maxWidth="280dp"
+            android:textColor="@color/md_theme_onBackground"
+            android:textSize="15sp" />
+
+        <TextView
+            android:id="@+id/offlineHint"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:gravity="center"
+            android:maxWidth="280dp"
+            android:textColor="@color/md_theme_primary"
+            android:textSize="13sp"
+            android:textStyle="bold"
+            android:visibility="gone" />
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/retryButton"
+            style="@style/Widget.Material3.Button"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="24dp"
+            android:minWidth="140dp"
+            android:text="@string/retry"
+            android:textColor="@color/md_theme_onPrimary"
+            app:backgroundTint="@color/md_theme_primary"
+            app:cornerRadius="999dp" />
+    </LinearLayout>
+
+    <com.google.android.material.progressindicator.CircularProgressIndicator
+        android:id="@+id/loadingIndicator"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:indicatorColor="@color/md_theme_primary"
+        app:indicatorSize="48dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 53 - 0
app/src/main/res/layout/activity_splash.xml

@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@android:color/white">
+
+    <ImageView
+        android:id="@+id/splashLogo"
+        android:layout_width="168dp"
+        android:layout_height="168dp"
+        android:contentDescription="@string/app_name"
+        android:scaleType="centerInside"
+        android:src="@drawable/app_logo"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintVertical_bias="0.38" />
+
+    <LinearLayout
+        android:id="@+id/splashFooter"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="24dp"
+        android:layout_marginEnd="24dp"
+        android:layout_marginBottom="32dp"
+        android:gravity="center"
+        android:orientation="vertical"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent">
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:gravity="center"
+            android:text="@string/splash_copyright"
+            android:textColor="@color/md_theme_onBackground"
+            android:textSize="12sp" />
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="6dp"
+            android:gravity="center"
+            android:text="@string/splash_support"
+            android:textColor="@color/md_theme_primary"
+            android:textSize="13sp"
+            android:textStyle="bold" />
+    </LinearLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 6 - 0
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+    <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
+</adaptive-icon>

+ 6 - 0
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+    <monochrome android:drawable="@drawable/ic_launcher_monochrome" />
+</adaptive-icon>

+ 7 - 0
app/src/main/res/mipmap-anydpi/ic_launcher.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="@color/ic_launcher_background" />
+    <item
+        android:gravity="center"
+        android:drawable="@drawable/ic_launcher_foreground" />
+</layer-list>

+ 7 - 0
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="@color/ic_launcher_background" />
+    <item
+        android:gravity="center"
+        android:drawable="@drawable/ic_launcher_foreground" />
+</layer-list>

+ 13 - 0
app/src/main/res/values-night/themes.xml

@@ -0,0 +1,13 @@
+<resources>
+    <style name="Theme.SafetyCheck" parent="Theme.Material3.DayNight.NoActionBar">
+        <item name="colorPrimary">@color/md_theme_primary</item>
+        <item name="colorOnPrimary">@color/md_theme_onPrimary</item>
+        <item name="android:statusBarColor">@android:color/transparent</item>
+    </style>
+
+    <style name="Theme.SafetyCheck.Launch" parent="Theme.Material3.Light.NoActionBar">
+        <item name="android:windowBackground">@android:color/white</item>
+        <item name="android:statusBarColor">@android:color/white</item>
+        <item name="android:navigationBarColor">@android:color/white</item>
+    </style>
+</resources>

+ 8 - 0
app/src/main/res/values/colors.xml

@@ -0,0 +1,8 @@
+<resources>
+    <color name="md_theme_primary">#0183FA</color>
+    <color name="md_theme_onPrimary">#FFFFFF</color>
+    <color name="md_theme_background">#F5F9FF</color>
+    <color name="md_theme_onBackground">#111827</color>
+    <color name="md_theme_surface">#FFFFFF</color>
+    <color name="md_theme_onSurface">#111827</color>
+</resources>

+ 3 - 0
app/src/main/res/values/ic_launcher_background.xml

@@ -0,0 +1,3 @@
+<resources>
+    <color name="ic_launcher_background">#0F766E</color>
+</resources>

+ 12 - 0
app/src/main/res/values/strings.xml

@@ -0,0 +1,12 @@
+<resources>
+    <string name="app_name">安全检查</string>
+    <string name="file_chooser_title">选择文件</string>
+    <string name="offline_no_network_title">当前无网络连接</string>
+    <string name="offline_no_network_message">请检查 Wi‑Fi 或移动网络后重试</string>
+    <string name="offline_server_title">服务暂时不可用</string>
+    <string name="offline_server_message">当前无法连接服务,请稍后重新加载</string>
+    <string name="offline_auto_retry_hint">网络恢复后将自动重新加载</string>
+    <string name="retry">重新加载</string>
+    <string name="splash_copyright">Copyright © 中国安全生产科学研究院</string>
+    <string name="splash_support">技术支持:江苏忠江智能科技有限公司</string>
+</resources>

+ 16 - 0
app/src/main/res/values/themes.xml

@@ -0,0 +1,16 @@
+<resources xmlns:tools="http://schemas.android.com/tools">
+    <style name="Theme.SafetyCheck" parent="Theme.Material3.DayNight.NoActionBar">
+        <item name="colorPrimary">@color/md_theme_primary</item>
+        <item name="colorOnPrimary">@color/md_theme_onPrimary</item>
+        <item name="android:statusBarColor">@android:color/transparent</item>
+        <item name="android:windowLightStatusBar" tools:targetApi="23">false</item>
+    </style>
+
+    <style name="Theme.SafetyCheck.Launch" parent="Theme.Material3.Light.NoActionBar">
+        <item name="android:windowBackground">@android:color/white</item>
+        <item name="android:statusBarColor">@android:color/white</item>
+        <item name="android:navigationBarColor">@android:color/white</item>
+        <item name="android:windowLightStatusBar" tools:targetApi="23">true</item>
+        <item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
+    </style>
+</resources>

+ 9 - 0
app/src/main/res/xml/file_paths.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths xmlns:android="http://schemas.android.com/apk/res/android">
+    <external-files-path
+        name="camera_images"
+        path="Pictures/" />
+    <cache-path
+        name="cache_images"
+        path="." />
+</paths>

+ 6 - 0
app/src/main/res/xml/network_security_config.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<network-security-config>
+    <domain-config cleartextTrafficPermitted="true">
+        <domain includeSubdomains="false">192.168.1.8</domain>
+    </domain-config>
+</network-security-config>

+ 4 - 0
build.gradle.kts

@@ -0,0 +1,4 @@
+plugins {
+    alias(libs.plugins.android.application) apply false
+    alias(libs.plugins.jetbrains.kotlin.android) apply false
+}

+ 4 - 0
gradle.properties

@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official

+ 19 - 0
gradle/libs.versions.toml

@@ -0,0 +1,19 @@
+[versions]
+agp = "8.7.0"
+kotlin = "1.9.24"
+coreKtx = "1.15.0"
+appcompat = "1.7.0"
+material = "1.12.0"
+activity = "1.9.3"
+constraintlayout = "2.2.0"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar


+ 5 - 0
gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 185 - 0
gradlew

@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+##  Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+    echo "$*"
+}
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+  CYGWIN* )
+    cygwin=true
+    ;;
+  Darwin* )
+    darwin=true
+    ;;
+  MINGW* )
+    msys=true
+    ;;
+  NONSTOP* )
+    nonstop=true
+    ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+        JAVACMD="$JAVA_HOME/bin/java"
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD="java"
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+    MAX_FD_LIMIT=`ulimit -H -n`
+    if [ $? -eq 0 ] ; then
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+            MAX_FD="$MAX_FD_LIMIT"
+        fi
+        ulimit -n $MAX_FD
+        if [ $? -ne 0 ] ; then
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
+        fi
+    else
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+    fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+    JAVACMD=`cygpath --unix "$JAVACMD"`
+
+    # We build the pattern for arguments to be converted via cygpath
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+    SEP=""
+    for dir in $ROOTDIRSRAW ; do
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
+        SEP="|"
+    done
+    OURCYGPATTERN="(^($ROOTDIRS))"
+    # Add a user-defined pattern to the cygpath arguments
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+    fi
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    i=0
+    for arg in "$@" ; do
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
+
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+        else
+            eval `echo args$i`="\"$arg\""
+        fi
+        i=`expr $i + 1`
+    done
+    case $i in
+        0) set -- ;;
+        1) set -- "$args0" ;;
+        2) set -- "$args0" "$args1" ;;
+        3) set -- "$args0" "$args1" "$args2" ;;
+        4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+        5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+        6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+        7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+        8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+        9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+    esac
+fi
+
+# Escape application args
+save () {
+    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+    echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"

+ 89 - 0
gradlew.bat

@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

BIN
safetychecklogopda.png


+ 24 - 0
settings.gradle.kts

@@ -0,0 +1,24 @@
+pluginManagement {
+    repositories {
+        google {
+            content {
+                includeGroupByRegex("com\\.android.*")
+                includeGroupByRegex("com\\.google.*")
+                includeGroupByRegex("androidx.*")
+            }
+        }
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "appForPDA"
+include(":app")