feat: 设备端 Agent 首个版本——剪贴板注入 / 应用商店 / 身份显示 / 配置下发
配合 auto_control 平台的设备端补位 App(接口契约见 INTERFACE.md,与平台仓库
doc/DEVICE_AGENT.md 是同一份)。
四个 adb 可拉起的 Activity + 一个商店界面:
- ClipActivity 剪贴板注入(透明 Activity,**必须等窗口获得焦点再写** ——
Android 10+ 只有前台应用能写剪贴板,这正是 u2 set_clipboard
"成功但内容被丢弃"的原因);写完立即退出,不驻留
- InfoActivity 全屏大字显示设备身份(名称/IP/序列号/平台地址),
替代原来"推 HTML 让设备浏览器打开"那套
- ConfigActivity 一次性配置下发(平台用 adb 把 server/token/指纹推进来),
一批设备不用一台台手输
- PingActivity 自检(版本/指纹/配置状态),结果写 logcat + 外部私有目录的
status.json,平台据此判断"装没装、哪一版"
- MainActivity 应用商店:拉平台清单 → 选应用 → 安装;已装的显示「已安装」,
有新版显示「更新到 vX」
实机踩出来的几个坑(都已处理):
- **应用身份读不到 ro.serialno**(shell 能读、应用被 SELinux 挡)→ 指纹改由平台
通过 ConfigActivity 下发(平台本来就知道),本机 getprop 只作兜底
- **Android 9+ 默认拦明文 HTTP** → 平台是局域网 http,需 usesCleartextTraffic
- **Android 11+ 软件包可见性** → 不加 QUERY_ALL_PACKAGES 会"明明装了却显示未安装"
- **MIUI 安装确认不止一步** → 装完检测窗口给到 3 分钟(人会依次点继续→勾选→继续更新)
- 上报带重试(实测遇到过一次网络抖动导致 timeout)
构建:JDK 17 + Android SDK(minSdk 29 / targetSdk 34),只依赖 androidx.core,
HTTP 用 HttpURLConnection、JSON 用系统内置 org.json。
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.example.deviceagent'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.example.deviceagent"
|
||||
minSdk 29 // Android 10;剪贴板限制就是从这一版开始的
|
||||
targetSdk 34
|
||||
versionCode 4
|
||||
versionName "1.3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false // 工具类 App,不混淆(方便排查)
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix ""
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
// 设备都是 arm64,只出一个架构的包,构建快、体积小
|
||||
splits {
|
||||
abi {
|
||||
enable false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 刻意只引一个 androidx.core:只为 FileProvider(把下载的 APK 授权给系统安装器读)。
|
||||
// HTTP 用 HttpURLConnection、JSON 用 org.json(都是系统内置),不引网络/序列化库——
|
||||
// 依赖越少,构建越快、出问题越好排查。
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
# 工具类 App,release 也不开混淆(见 app/build.gradle 的 minifyEnabled false)。
|
||||
# 这里留空;将来若要开混淆,注意别把 Activity 类名混淆掉——
|
||||
# 平台是通过 `am start -n com.example.deviceagent/.ClipActivity` 这种**显式类名**拉起的,
|
||||
# 类名被混淆就调不起来了。
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!--
|
||||
usesCleartextTraffic=true 的原因:平台跑在局域网里、只有 HTTP(没有 TLS),
|
||||
Android 9+ 默认会拦掉明文请求(实测报 "Cleartext HTTP traffic ... not permitted")。
|
||||
本 App 只连你在「配置」里填的那个平台地址,不传输用户隐私数据。
|
||||
将来平台上了 HTTPS,把 application 上那一行删掉即可。
|
||||
-->
|
||||
|
||||
<!-- 从平台拉应用清单 / 下载 APK -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- 自己调系统安装器安装 APK(这正是绕开 MIUI「USB 安装」拦截的关键) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!--
|
||||
查询任意已安装应用的版本号 —— 用来在商店列表里显示「已安装 / 可更新 / 安装」。
|
||||
Android 11+ 默认限制了软件包可见性(不给的话 getPackageInfo 一律抛 NameNotFound,
|
||||
实测表现为"明明装了却全显示未安装");本 App 不是上架 Google Play 的应用,
|
||||
用这个权限没有商店审核问题。
|
||||
-->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.DeviceAgent">
|
||||
|
||||
<!-- 主界面:应用商店(看清单、选应用、下载安装) -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- 剪贴板注入:透明 Activity,由平台用 adb am start 拉起
|
||||
(完整命令见 README「快速开始」,参数 text_b64 传 base64 文本) -->
|
||||
<activity
|
||||
android:name=".ClipActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:theme="@style/Theme.DeviceAgent.Transparent" />
|
||||
|
||||
<!-- 身份显示:全屏大字,人在机架前一眼看出这台是平台的哪一台
|
||||
(参数 name / ip / serial / server,close=1 关闭;命令见 README) -->
|
||||
<activity
|
||||
android:name=".InfoActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:excludeFromRecents="true" />
|
||||
|
||||
<!-- 一次性配置下发(平台用 adb 推地址与令牌,省得每台手输) -->
|
||||
<activity
|
||||
android:name=".ConfigActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:theme="@style/Theme.DeviceAgent.Transparent" />
|
||||
|
||||
<!-- 自检:平台用来判断"装没装、哪一版" -->
|
||||
<activity
|
||||
android:name=".PingActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:theme="@style/Theme.DeviceAgent.Transparent" />
|
||||
|
||||
<!-- 把下载到私有目录的 APK 授权给系统安装器读 -->
|
||||
<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>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.example.deviceagent
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* 平台侧「设备端应用商店」接口的客户端。
|
||||
*
|
||||
* 协议见平台仓库的 doc/DEVICE_AGENT.md —— 三个接口:
|
||||
* GET /api/device/agent/bootstrap 我是谁 + 有哪些应用可装
|
||||
* GET /api/device/agent/apk/{id} 下载 APK
|
||||
* POST /api/device/agent/report 回报下载/安装结果
|
||||
*
|
||||
* 只用 HttpURLConnection + org.json(系统内置),不引第三方库。
|
||||
*/
|
||||
object Api {
|
||||
private const val TAG = "DeviceAgent"
|
||||
private const val TIMEOUT = 15000
|
||||
|
||||
class ApiException(val code: Int, message: String) : Exception(message)
|
||||
|
||||
private fun open(ctx: Context, path: String, method: String): HttpURLConnection {
|
||||
val base = Prefs.server(ctx)
|
||||
if (base.isEmpty()) throw ApiException(-1, "还没配置平台地址")
|
||||
val conn = URL(base + path).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = method
|
||||
conn.connectTimeout = TIMEOUT
|
||||
conn.readTimeout = TIMEOUT
|
||||
conn.setRequestProperty("X-Device-Token", Prefs.token(ctx))
|
||||
conn.setRequestProperty("X-Device-Fingerprint", Prefs.fingerprint(ctx))
|
||||
Prefs.localIp().takeIf { it.isNotEmpty() }?.let {
|
||||
conn.setRequestProperty("X-Device-Serial", "$it:5555")
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
private fun errText(conn: HttpURLConnection): String = try {
|
||||
conn.errorStream?.bufferedReader()?.use { it.readText() }?.take(200) ?: ""
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
/** 拉身份 + 应用清单。失败抛 ApiException(带 HTTP 码与人类可读原因)。 */
|
||||
fun bootstrap(ctx: Context): JSONObject {
|
||||
val conn = open(ctx, "/api/device/agent/bootstrap", "GET")
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
if (code != 200) {
|
||||
throw ApiException(code, when (code) {
|
||||
404 -> "平台没启用设备端商店(或地址不对)"
|
||||
401 -> "设备令牌不对(可能被管理员重置了)"
|
||||
else -> "平台返回 $code ${errText(conn)}"
|
||||
})
|
||||
}
|
||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
return JSONObject(body)
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载 APK 到私有目录,返回文件。失败抛 ApiException。 */
|
||||
fun downloadApk(ctx: Context, downloadPath: String, apkId: String): File {
|
||||
val dir = File(ctx.filesDir, "apks").apply { mkdirs() }
|
||||
val out = File(dir, "$apkId.apk")
|
||||
val conn = open(ctx, downloadPath, "GET")
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
if (code != 200) throw ApiException(code, "下载失败(HTTP $code ${errText(conn)})")
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(out).use { fos -> input.copyTo(fos, 64 * 1024) }
|
||||
}
|
||||
if (out.length() <= 0) throw ApiException(-2, "下载到的文件是空的")
|
||||
return out
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请**平台**帮我装这个应用(平台走 adb → 静默,没有系统安装框)。
|
||||
*
|
||||
* 为什么不让本机自己装:普通 App 调系统安装器,MIUI 上要过「继续 → 勾选未经检测
|
||||
* → 继续更新」甚至 ICP 备案检查;而 adb 装在同一台机器上实测是静默的。
|
||||
* 返回 (ok, 说明);ok=false 时调用方应退回本机安装。
|
||||
*/
|
||||
fun requestPlatformInstall(ctx: Context, apkId: String): Pair<Boolean, String> {
|
||||
return try {
|
||||
val conn = open(ctx, "/api/device/agent/install", "POST")
|
||||
try {
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
conn.outputStream.use {
|
||||
it.write(JSONObject().put("apk_id", apkId)
|
||||
.toString().toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val code = conn.responseCode
|
||||
val body = (if (code == 200) conn.inputStream else conn.errorStream)
|
||||
?.bufferedReader()?.use { r -> r.readText() } ?: ""
|
||||
val j = try {
|
||||
JSONObject(body)
|
||||
} catch (e: Exception) {
|
||||
JSONObject()
|
||||
}
|
||||
if (code == 200 && j.optBoolean("ok")) {
|
||||
true to j.optString("msg", "平台已开始安装")
|
||||
} else {
|
||||
false to j.optString("error", "平台返回 $code")
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false to (e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报结果。**带重试**:实机实测遇到过一次 timeout(手机在后台、网络抖了一下),
|
||||
* 而这条记录是平台「设备端商店」面板唯一的可见性来源,丢一条就少一条。
|
||||
* 三次都失败也只记日志,绝不阻塞安装流程。
|
||||
*/
|
||||
fun report(ctx: Context, apkId: String, action: String,
|
||||
packageName: String = "", versionName: String = "", message: String = "") {
|
||||
val body = JSONObject().apply {
|
||||
put("apk_id", apkId)
|
||||
put("action", action)
|
||||
put("package_name", packageName)
|
||||
put("version_name", versionName)
|
||||
put("message", message.take(400))
|
||||
}.toString()
|
||||
for (attempt in 1..3) {
|
||||
try {
|
||||
val conn = open(ctx, "/api/device/agent/report", "POST")
|
||||
try {
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
val code = conn.responseCode
|
||||
Log.i(TAG, "report $action -> HTTP $code(第 $attempt 次)")
|
||||
if (code == 200) return
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "上报失败(第 $attempt 次): ${e.message}")
|
||||
try {
|
||||
Thread.sleep(1500L * attempt)
|
||||
} catch (_: InterruptedException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "上报 $action 三次都没成功(不影响安装,平台面板会缺这条记录)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.example.deviceagent
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* 剪贴板注入(透明 Activity,由平台用 adb 拉起):
|
||||
*
|
||||
* adb shell am start -n com.example.deviceagent/.ClipActivity --es text_b64 <base64(UTF-8)>
|
||||
*
|
||||
* 为什么必须是个 Activity:**Android 10 起,只有当前有焦点的应用才能写剪贴板**——
|
||||
* shell / 后台进程写会被系统静默丢弃(这正是 u2 的 set_clipboard "成功但内容丢失"的原因)。
|
||||
*
|
||||
* 时序要点:onCreate 时窗口还没拿到焦点,**必须等 onWindowFocusChanged(true) 再写**,
|
||||
* 否则写入会被拒。这里再加一个 800ms 兜底,避免个别机型不发焦点回调时卡住。
|
||||
*/
|
||||
class ClipActivity : Activity() {
|
||||
|
||||
private var done = false
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.i(TAG, "ClipActivity 启动")
|
||||
handler.postDelayed({ writeClip() }, 800)
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) writeClip()
|
||||
}
|
||||
|
||||
private fun writeClip() {
|
||||
if (done) return
|
||||
done = true
|
||||
val b64 = intent?.getStringExtra("text_b64").orEmpty()
|
||||
if (b64.isEmpty()) {
|
||||
Log.w(TAG, "ClipActivity 没收到 text_b64")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val text = String(Base64.decode(b64, Base64.DEFAULT), Charsets.UTF_8)
|
||||
val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("deviceagent", text))
|
||||
// 读回自证(平台侧也会再验一次)
|
||||
val got = cm.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString() ?: ""
|
||||
if (got == text) Log.i(TAG, "clip ok len=${text.length}")
|
||||
else Log.w(TAG, "clip 读回不一致(写入可能被拒)got=${got.length} want=${text.length}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "clip 写入异常: ${e.message}")
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "DeviceAgent"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.example.deviceagent
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
|
||||
/**
|
||||
* 一次性配置下发(透明 Activity,由平台用 adb 拉起):
|
||||
*
|
||||
* adb shell am start -n com.example.deviceagent/.ConfigActivity \
|
||||
* --es server http://192.168.20.220:18050 --es token <设备令牌> \
|
||||
* --es fingerprint <ro.serialno>
|
||||
*
|
||||
* 有了它,一批设备不用一台台手输地址和令牌。
|
||||
* 只写传进来的字段;三个都能单独更新。
|
||||
*
|
||||
* ⚠️ fingerprint 必须由平台推下来:实测应用身份读不到 ro.serialno
|
||||
*(shell 能读、应用被 SELinux 挡住),而平台通过 adb 本来就知道这个值。
|
||||
*/
|
||||
class ConfigActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val server = intent?.getStringExtra("server")
|
||||
val token = intent?.getStringExtra("token")
|
||||
val fp = intent?.getStringExtra("fingerprint")
|
||||
if (server.isNullOrEmpty() && token.isNullOrEmpty() && fp.isNullOrEmpty()) {
|
||||
Log.w(TAG, "ConfigActivity 没收到 server/token/fingerprint")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
try {
|
||||
Prefs.save(this, server, token, fp)
|
||||
val msg = "配置已更新:${Prefs.server(this)} / 令牌${if (Prefs.token(this).isEmpty()) "空" else "已设置"} / 指纹${if (Prefs.fingerprint(this).isEmpty()) "空" else "已设置"}"
|
||||
Log.i(TAG, msg)
|
||||
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "配置写入失败: ${e.message}")
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "DeviceAgent"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.example.deviceagent
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 设备身份大字页(由平台用 adb 拉起)——**替代以前"推一个 HTML 页面让设备浏览器打开"那套**:
|
||||
*
|
||||
* adb shell am start -n com.example.deviceagent/.InfoActivity \
|
||||
* --es name A08 --es ip 192.168.20.100 --es serial 192.168.20.100:5555 \
|
||||
* --es server http://192.168.20.220:18050
|
||||
* # 关闭:--es close 1
|
||||
*
|
||||
* 用途:人站在机架前,一眼看出"手头这台是平台上的哪一台";排查断联时不用连电脑。
|
||||
* 再次调用会覆盖内容(launchMode=singleTop + onNewIntent)。
|
||||
*/
|
||||
class InfoActivity : Activity() {
|
||||
|
||||
private lateinit var tvName: TextView
|
||||
private lateinit var tvLines: TextView
|
||||
private lateinit var tvTime: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
buildUi()
|
||||
render(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
if (intent?.getStringExtra("close") == "1") {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
render(intent)
|
||||
}
|
||||
|
||||
private fun buildUi() {
|
||||
val root = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(Color.BLACK)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(48, 64, 48, 64)
|
||||
}
|
||||
tvName = TextView(this).apply {
|
||||
setTextColor(Color.WHITE)
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 72f)
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
tvLines = TextView(this).apply {
|
||||
setTextColor(Color.parseColor("#FFD54F"))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 26f)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(0, 40, 0, 40)
|
||||
}
|
||||
tvTime = TextView(this).apply {
|
||||
setTextColor(Color.parseColor("#9E9E9E"))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
root.addView(tvName)
|
||||
root.addView(tvLines)
|
||||
root.addView(tvTime)
|
||||
setContentView(root)
|
||||
}
|
||||
|
||||
private fun render(intent: Intent?) {
|
||||
val name = intent?.getStringExtra("name").orEmpty()
|
||||
val ip = intent?.getStringExtra("ip").orEmpty()
|
||||
val serial = intent?.getStringExtra("serial").orEmpty()
|
||||
val server = intent?.getStringExtra("server").orEmpty()
|
||||
tvName.text = if (name.isNotEmpty()) name else "未登记"
|
||||
tvLines.text = listOf(
|
||||
"IP $ip",
|
||||
"序列号 $serial",
|
||||
"平台 $server",
|
||||
"指纹 ${Prefs.fingerprint(this)}",
|
||||
"Agent v${Prefs.AGENT_VERSION}"
|
||||
).filter { it.substringAfter(" ").trim().isNotEmpty() }.joinToString("\n")
|
||||
tvTime.text = "更新于 " + SimpleDateFormat("HH:mm:ss", Locale.CHINA).format(Date())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package com.example.deviceagent
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.FileProvider
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 应用商店主界面:从平台拉可安装的应用清单 → 选一个 → 下载 → 调系统安装器安装。
|
||||
*
|
||||
* **为什么这条路能绕开 MIUI 的「USB 安装」拦截**:装应用的是设备上的 App(走普通
|
||||
* 应用安装流程),而不是 adb(`adb install` 会被 MIUI 拦成 USER_RESTRICTED)。
|
||||
*/
|
||||
class MainActivity : Activity() {
|
||||
|
||||
private val ui = Handler(Looper.getMainLooper())
|
||||
private lateinit var tvDevice: TextView
|
||||
private lateinit var tvServer: TextView
|
||||
private lateinit var tvStatus: TextView
|
||||
private lateinit var listBox: LinearLayout
|
||||
private var deviceName = ""
|
||||
private var deviceSerial = ""
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
buildUi()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshDeviceInfo()
|
||||
if (Prefs.configured(this)) refreshList() else status("还没配置平台地址和令牌,点上面「配置」")
|
||||
}
|
||||
|
||||
// ================== 界面 ==================
|
||||
private fun buildUi() {
|
||||
val root = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(32, 40, 32, 32)
|
||||
}
|
||||
|
||||
tvDevice = TextView(this).apply {
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
|
||||
setTextColor(Color.parseColor("#111827"))
|
||||
}
|
||||
tvServer = TextView(this).apply {
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
|
||||
setTextColor(Color.parseColor("#6B7280"))
|
||||
setPadding(0, 8, 0, 16)
|
||||
}
|
||||
|
||||
val toolbar = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
|
||||
toolbar.addView(btn("配置") { showConfigDialog() })
|
||||
toolbar.addView(btn("刷新清单") { refreshList() })
|
||||
|
||||
tvStatus = TextView(this).apply {
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
|
||||
setTextColor(Color.parseColor("#374151"))
|
||||
setPadding(0, 12, 0, 12)
|
||||
}
|
||||
listBox = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||
|
||||
root.addView(tvDevice)
|
||||
root.addView(tvServer)
|
||||
root.addView(toolbar)
|
||||
root.addView(tvStatus)
|
||||
root.addView(listBox)
|
||||
setContentView(ScrollView(this).apply { addView(root) })
|
||||
}
|
||||
|
||||
private fun btn(text: String, onClick: () -> Unit): Button =
|
||||
Button(this).apply {
|
||||
this.text = text
|
||||
setOnClickListener { onClick() }
|
||||
}
|
||||
|
||||
private fun status(s: String) {
|
||||
tvStatus.text = s
|
||||
Log.i(TAG, s)
|
||||
}
|
||||
|
||||
private fun refreshDeviceInfo() {
|
||||
val fp = Prefs.fingerprint(this)
|
||||
val ip = Prefs.localIp()
|
||||
tvDevice.text = (if (deviceName.isNotEmpty()) deviceName else "未登记") +
|
||||
(if (ip.isNotEmpty()) " $ip" else "")
|
||||
tvServer.text = "平台 ${Prefs.server(this).ifEmpty { "(未配置)" }} " +
|
||||
"令牌 ${if (Prefs.token(this).isEmpty()) "(未设置)" else "已设置"} " +
|
||||
"指纹 $fp"
|
||||
}
|
||||
|
||||
// ================== 拉清单 ==================
|
||||
private fun refreshList() {
|
||||
if (!Prefs.configured(this)) {
|
||||
status("还没配置平台地址和令牌")
|
||||
return
|
||||
}
|
||||
status("正在拉取应用清单…")
|
||||
listBox.removeAllViews()
|
||||
Thread {
|
||||
try {
|
||||
val j = Api.bootstrap(this)
|
||||
val device = j.optJSONObject("device") ?: JSONObject()
|
||||
deviceName = device.optString("name")
|
||||
deviceSerial = device.optString("serial")
|
||||
val apks = j.optJSONArray("apks") ?: JSONArray()
|
||||
ui.post {
|
||||
refreshDeviceInfo()
|
||||
status("平台上有 ${apks.length()} 个应用" +
|
||||
if (deviceName.isNotEmpty()) "(平台识别为 $deviceName)" else "(平台还不认识这台设备)")
|
||||
renderApks(apks)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ui.post { status("拉取失败:${e.message}") }
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun renderApks(apks: JSONArray) {
|
||||
listBox.removeAllViews()
|
||||
for (i in 0 until apks.length()) {
|
||||
val a = apks.optJSONObject(i) ?: continue
|
||||
listBox.addView(apkRow(a))
|
||||
}
|
||||
if (apks.length() == 0) {
|
||||
listBox.addView(TextView(this).apply {
|
||||
text = "平台的应用管理里还没有上传任何 APK"
|
||||
setTextColor(Color.parseColor("#9CA3AF"))
|
||||
setPadding(0, 24, 0, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun apkRow(a: JSONObject): View {
|
||||
val wrap = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, 20, 0, 20)
|
||||
}
|
||||
val name = a.optString("display_name").ifEmpty { a.optString("package_name") }
|
||||
val ver = a.optString("version_name")
|
||||
val size = a.optLong("size")
|
||||
val pkg = a.optString("package_name")
|
||||
val offeredCode = a.optLong("version_code")
|
||||
|
||||
// 本机装没装、装的是哪一版 —— 按钮据此变成 安装 / 更新 / 已安装
|
||||
val installedCode = installedVersionCode(pkg)
|
||||
val installedVer = installedVersionName(pkg)
|
||||
|
||||
wrap.addView(TextView(this).apply {
|
||||
text = name + (if (ver.isNotEmpty()) " v$ver" else "")
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
|
||||
setTextColor(Color.parseColor("#111827"))
|
||||
})
|
||||
wrap.addView(TextView(this).apply {
|
||||
text = "$pkg ${fmtSize(size)}"
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
|
||||
setTextColor(Color.parseColor("#6B7280"))
|
||||
})
|
||||
|
||||
val row = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
}
|
||||
val note = TextView(this).apply {
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
|
||||
setPadding(16, 0, 0, 0)
|
||||
}
|
||||
when {
|
||||
installedCode == 0L -> {
|
||||
row.addView(btn("安装") { confirmInstall(a) })
|
||||
note.text = "未安装"
|
||||
note.setTextColor(Color.parseColor("#9CA3AF"))
|
||||
}
|
||||
offeredCode > installedCode -> {
|
||||
row.addView(btn("更新到 v$ver") { confirmInstall(a) })
|
||||
note.text = "已安装 v$installedVer"
|
||||
note.setTextColor(Color.parseColor("#B45309")) // 提示可更新
|
||||
}
|
||||
else -> {
|
||||
val b = btn("已安装") {}
|
||||
b.isEnabled = false
|
||||
row.addView(b)
|
||||
note.text = "本机 v$installedVer(已是最新)"
|
||||
note.setTextColor(Color.parseColor("#059669"))
|
||||
}
|
||||
}
|
||||
row.addView(note)
|
||||
wrap.addView(row)
|
||||
return wrap
|
||||
}
|
||||
|
||||
/** 本机已装应用的 versionCode(未安装返回 0) */
|
||||
private fun installedVersionCode(pkg: String): Long {
|
||||
if (pkg.isEmpty()) return 0L
|
||||
return try {
|
||||
val info = packageManager.getPackageInfo(pkg, 0)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode
|
||||
else @Suppress("DEPRECATION") info.versionCode.toLong()
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun installedVersionName(pkg: String): String = try {
|
||||
packageManager.getPackageInfo(pkg, 0).versionName ?: ""
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
private fun fmtSize(n: Long): String = when {
|
||||
n <= 0 -> ""
|
||||
n < 1024 -> "$n B"
|
||||
n < 1024 * 1024 -> String.format("%.1f KB", n / 1024.0)
|
||||
else -> String.format("%.1f MB", n / 1024.0 / 1024.0)
|
||||
}
|
||||
|
||||
// ================== 安装 ==================
|
||||
private fun confirmInstall(a: JSONObject) {
|
||||
val name = a.optString("display_name").ifEmpty { a.optString("package_name") }
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("安装 $name")
|
||||
.setMessage("会请平台用 adb 帮你静默安装(几秒钟,不用在手机上点确认)。\n\n" +
|
||||
"平台装不了时会自动退回本机安装——那条路 MIUI 会要求点几下确认。")
|
||||
.setPositiveButton("安装") { _, _ -> doInstall(a) }
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun doInstall(a: JSONObject) {
|
||||
val apkId = a.optString("id")
|
||||
val pkg = a.optString("package_name")
|
||||
val ver = a.optString("version_name")
|
||||
val name = a.optString("display_name").ifEmpty { pkg }
|
||||
val wantCode = a.optLong("version_code")
|
||||
|
||||
// 先请平台装:平台走 adb,在 MIUI 上是**静默**的(实测升级 2.7s、全新安装 7.9s,
|
||||
// 零弹框)。本机自己装要过系统安装器的几道确认,所以只作为兜底。
|
||||
status("已请平台安装 $name …")
|
||||
Thread {
|
||||
val (okPlatform, why) = Api.requestPlatformInstall(this, apkId)
|
||||
if (okPlatform) {
|
||||
ui.post { status("平台正在静默安装 $name(走 adb,不用点确认)…") }
|
||||
val ok = waitVersion(pkg, wantCode, 40) // 最长 2 分钟
|
||||
Api.report(this, apkId, if (ok) "install_ok" else "install_fail", pkg, ver,
|
||||
if (ok) "" else "平台安装后没检测到新版本(adb 可能被 MIUI 拦了)")
|
||||
ui.post {
|
||||
status(if (ok) "✅ $name 安装成功(已回报平台)"
|
||||
else "⚠️ $name 平台没能装上(已回报平台),可再点一次试试本机安装")
|
||||
if (ok) refreshList() // 按钮从「安装/更新」变成「已安装」
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
// 平台这条路不通(没登记进设备池 / adb 被拦)→ 退回本机安装
|
||||
ui.post {
|
||||
status("平台安装不可用($why),改用本机安装(会有系统确认框)…")
|
||||
localInstall(a)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
/** 本机安装兜底:自己下载 + 调系统安装器(MIUI 上用户要点几步确认) */
|
||||
private fun localInstall(a: JSONObject) {
|
||||
if (!canInstallPackages()) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("缺少安装权限")
|
||||
.setMessage("需要在系统设置里允许「${getString(R.string.app_name)}」安装应用。" +
|
||||
"\n\n提示:平台也可以用 adb 预先授权,见 README。")
|
||||
.setPositiveButton("去设置") { _, _ ->
|
||||
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:$packageName")))
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
val apkId = a.optString("id")
|
||||
val path = a.optString("download_path")
|
||||
val pkg = a.optString("package_name")
|
||||
val ver = a.optString("version_name")
|
||||
val name = a.optString("display_name").ifEmpty { pkg }
|
||||
status("正在下载 $name …")
|
||||
Thread {
|
||||
try {
|
||||
val f = Api.downloadApk(this, path, apkId)
|
||||
Api.report(this, apkId, "download", pkg, ver)
|
||||
ui.post {
|
||||
status("下载完成(${fmtSize(f.length())}),请在系统弹框里点「继续安装」")
|
||||
startInstaller(f)
|
||||
watchInstall(apkId, pkg, ver, name)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
status("下载失败:${e.message}")
|
||||
Api.report(this, apkId, "install_fail", pkg, ver, "下载失败: ${e.message}")
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun startInstaller(f: File) {
|
||||
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", f)
|
||||
val i = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(i)
|
||||
}
|
||||
|
||||
/** 等本机版本升到期望的 versionCode(平台 adb 安装是异步的) */
|
||||
private fun waitVersion(pkg: String, wantCode: Long, tries: Int): Boolean {
|
||||
for (i in 0 until tries) {
|
||||
Thread.sleep(3000)
|
||||
val now = installedVersionCode(pkg)
|
||||
if (wantCode > 0 && now >= wantCode) return true
|
||||
if (wantCode <= 0 && now > 0) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 本机安装路径专用:装完没有?——系统安装器不给我们回调,用"lastUpdateTime 变没变"判断。
|
||||
* 窗口给到 **3 分钟**:MIUI 上人要依次点「继续」→ 勾「已了解未经安全检测」→「继续更新」,
|
||||
* 60 秒根本不够(第一次实测就是这么误报失败的)。
|
||||
*/
|
||||
private fun watchInstall(apkId: String, pkg: String, ver: String, name: String) {
|
||||
val before = lastUpdateTime(pkg)
|
||||
Thread {
|
||||
var ok = false
|
||||
for (i in 0 until 60) { // 60 × 3s = 最长 3 分钟
|
||||
Thread.sleep(3000)
|
||||
val now = lastUpdateTime(pkg)
|
||||
if (now > 0 && now != before) { ok = true; break }
|
||||
}
|
||||
val msg = if (ok) "" else "3 分钟内没检测到安装完成(可能没点系统安装框的确认)"
|
||||
Api.report(this, apkId, if (ok) "install_ok" else "install_fail", pkg, ver, msg)
|
||||
ui.post {
|
||||
status(if (ok) "✅ $name 安装成功(已回报平台)" else "⚠️ $name $msg(已回报平台)")
|
||||
if (ok) refreshList()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun lastUpdateTime(pkg: String): Long = try {
|
||||
packageManager.getPackageInfo(pkg, 0).lastUpdateTime
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
private fun canInstallPackages(): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
packageManager.canRequestPackageInstalls()
|
||||
} else true
|
||||
|
||||
// ================== 配置 ==================
|
||||
private fun showConfigDialog() {
|
||||
val box = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(32, 16, 32, 0)
|
||||
}
|
||||
val etServer = EditText(this).apply {
|
||||
hint = "平台地址,如 http://192.168.20.220:18050"
|
||||
inputType = InputType.TYPE_TEXT_VARIATION_URI
|
||||
setText(Prefs.server(this@MainActivity))
|
||||
}
|
||||
val etToken = EditText(this).apply {
|
||||
hint = "设备令牌"
|
||||
setText(Prefs.token(this@MainActivity))
|
||||
}
|
||||
box.addView(etServer)
|
||||
box.addView(etToken)
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("平台配置")
|
||||
.setView(box)
|
||||
.setMessage("一般不用在这里手输:平台可以用 adb 把地址和令牌推到设备上。")
|
||||
.setPositiveButton("保存") { _, _ ->
|
||||
Prefs.save(this, etServer.text.toString(), etToken.text.toString())
|
||||
refreshDeviceInfo()
|
||||
refreshList()
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "DeviceAgent"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.example.deviceagent
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 自检(透明 Activity,由平台用 adb 拉起):
|
||||
*
|
||||
* adb shell am start -n com.example.deviceagent/.PingActivity
|
||||
*
|
||||
* 平台靠它判断"这台设备装没装 Agent、装的哪一版"。`am start` 拿不到返回值,
|
||||
* 所以结果同时写两处(都用 adb 读得到,不需要任何权限):
|
||||
* 1) logcat: adb logcat -d -s DeviceAgent:I | grep AGENT_INFO
|
||||
* 2) 应用外部私有目录:
|
||||
* adb shell cat /sdcard/Android/data/com.example.deviceagent/files/status.json
|
||||
*/
|
||||
class PingActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val info = JSONObject().apply {
|
||||
put("package", packageName)
|
||||
put("agent_version", Prefs.AGENT_VERSION)
|
||||
put("agent_api_version", Prefs.API_VERSION)
|
||||
put("fingerprint", Prefs.fingerprint(this@PingActivity))
|
||||
put("local_ip", Prefs.localIp())
|
||||
put("server", Prefs.server(this@PingActivity))
|
||||
put("has_token", Prefs.token(this@PingActivity).isNotEmpty())
|
||||
put("android", Build.VERSION.RELEASE)
|
||||
put("model", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
}
|
||||
val text = info.toString()
|
||||
Log.i(TAG, "AGENT_INFO $text")
|
||||
try {
|
||||
val dir = getExternalFilesDir(null)
|
||||
if (dir != null) {
|
||||
dir.mkdirs()
|
||||
File(dir, "status.json").writeText(text, Charsets.UTF_8)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "写 status.json 失败: ${e.message}")
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "DeviceAgent"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.example.deviceagent
|
||||
|
||||
import android.content.Context
|
||||
import java.io.BufferedReader
|
||||
|
||||
/**
|
||||
* 本机配置(平台地址 + 设备令牌)与设备标识。
|
||||
*
|
||||
* 这两个值一般不用手输:平台可以用 adb 一次性推下来——
|
||||
* adb shell am start -n com.example.deviceagent/.ConfigActivity \
|
||||
* --es server http://192.168.20.220:18050 --es token <令牌>
|
||||
*/
|
||||
object Prefs {
|
||||
/** Agent 自身版本,与平台侧 doc/DEVICE_AGENT.md 的契约版本对齐 */
|
||||
const val AGENT_VERSION = "1.0"
|
||||
const val API_VERSION = 1
|
||||
|
||||
private const val FILE = "device_agent"
|
||||
private const val K_SERVER = "server"
|
||||
private const val K_TOKEN = "token"
|
||||
private const val K_FP = "fingerprint"
|
||||
|
||||
private fun sp(ctx: Context) =
|
||||
ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
|
||||
fun server(ctx: Context): String = sp(ctx).getString(K_SERVER, "") ?: ""
|
||||
fun token(ctx: Context): String = sp(ctx).getString(K_TOKEN, "") ?: ""
|
||||
|
||||
/** 只写传进来的字段(传 null 表示不动) */
|
||||
fun save(ctx: Context, server: String?, token: String?, fingerprint: String? = null) {
|
||||
val e = sp(ctx).edit()
|
||||
if (server != null) e.putString(K_SERVER, server.trim().trimEnd('/'))
|
||||
if (token != null) e.putString(K_TOKEN, token.trim())
|
||||
if (fingerprint != null) e.putString(K_FP, fingerprint.trim())
|
||||
e.apply()
|
||||
}
|
||||
|
||||
fun configured(ctx: Context): Boolean =
|
||||
server(ctx).isNotEmpty() && token(ctx).isNotEmpty()
|
||||
|
||||
/**
|
||||
* 设备指纹(`ro.serialno`,与平台用 adb 读到的是同一个值)。
|
||||
*
|
||||
* ⚠️ 实测(Android 12 / MIUI):**应用身份读 `getprop ro.serialno` 会返回空**——
|
||||
* shell 能读、应用被 SELinux 挡住了。所以:
|
||||
* 1) 优先用平台配置时推下来的值(平台通过 adb 本来就知道,见 ConfigActivity)
|
||||
* 2) 没有再自己试 getprop(老系统/其它 ROM 可能能读)
|
||||
* 不用 Build.getSerial()(要 READ_PHONE_STATE 敏感权限)、也不用 ANDROID_ID
|
||||
* (那是另一个值,和平台记录的对不上)。
|
||||
*/
|
||||
fun fingerprint(ctx: Context): String {
|
||||
val saved = sp(ctx).getString(K_FP, "") ?: ""
|
||||
if (saved.isNotEmpty()) return saved
|
||||
val v = getProp("ro.serialno")
|
||||
if (v.isNotEmpty()) return v
|
||||
return getProp("ro.boot.serialno")
|
||||
}
|
||||
|
||||
private fun getProp(key: String): String = try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("getprop", key))
|
||||
val out = p.inputStream.bufferedReader().use(BufferedReader::readText).trim()
|
||||
p.waitFor()
|
||||
out
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
/** 本机 Wi-Fi/以太网 IPv4 地址(只作为给平台的提示,认设备仍以指纹为准) */
|
||||
fun localIp(): String = try {
|
||||
java.net.NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { it.isUp && !it.isLoopback }
|
||||
.flatMap { it.inetAddresses.toList() }
|
||||
.filterIsInstance<java.net.Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress }?.hostAddress ?: ""
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 矢量图标:省掉各分辨率 PNG,包更小 -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="#2F6FED"
|
||||
android:pathData="M0,0h48v48h-48z" />
|
||||
<!-- 手机外形 -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M16,8h16a3,3 0 0 1 3,3v26a3,3 0 0 1 -3,3h-16a3,3 0 0 1 -3,-3v-26a3,3 0 0 1 3,-3zM16,12v20h16v-20z" />
|
||||
<!-- 屏幕里的勾 -->
|
||||
<path
|
||||
android:fillColor="#2F6FED"
|
||||
android:pathData="M19,22l3.5,3.5 7,-7 1.5,1.5 -8.5,8.5 -5,-5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">设备 Agent</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 用平台自带 Material 主题,不依赖 AppCompat(少一个依赖,构建更快) -->
|
||||
<style name="Theme.DeviceAgent" parent="@android:style/Theme.Material.Light.NoActionBar" />
|
||||
|
||||
<!-- 透明主题:剪贴板注入 / 配置下发 / 自检 都用它——
|
||||
这些 Activity 需要短暂获得焦点(写剪贴板必须有焦点),但绝不能挡住用户界面 -->
|
||||
<style name="Theme.DeviceAgent.Transparent" parent="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- FileProvider 可对外授权的路径:只放"下载下来的 APK"这一处私有目录,
|
||||
交给系统安装器读。范围越小越安全。 -->
|
||||
<paths>
|
||||
<files-path name="apks" path="apks/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user