feat: App 应用内更新

This commit is contained in:
yangxisong 2025-11-20 18:21:29 +08:00
parent c9a2b78a74
commit 0b0c863765
20 changed files with 324 additions and 66 deletions

5
.gitignore vendored
View File

@ -23,13 +23,10 @@ misc.xml
deploymentTargetDropDown.xml deploymentTargetDropDown.xml
render.experimental.xml render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase) # Google Services (e.g. APIs or Firebase)
google-services.json google-services.json
# Android Profiling # Android Profiling
*.hprof *.hprof
app/release

View File

@ -1,8 +1,14 @@
import java.io.FileInputStream
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
} }
val keystores: Map<String, Properties?> =
mapOf("release" to loadKeystoreProperties("keystore.properties"))
android { android {
namespace = "com.yuanxuan.rokid" namespace = "com.yuanxuan.rokid"
compileSdk { compileSdk {
@ -19,8 +25,23 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
keystores["release"]?.let { proerties ->
signingConfigs {
create("release") {
storeFile = rootProject.file(proerties["storeFile"] as String)
storePassword = proerties["storePassword"] as String
keyAlias = proerties["keyAlias"] as String
keyPassword = proerties["keyPassword"] as String
}
}
}
buildTypes { buildTypes {
debug {
signingConfig = signingConfigs.getByName("release")
}
release { release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
@ -59,4 +80,16 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
}
fun loadKeystoreProperties(filename: String): Properties? {
val keystorePropertiesFile = file("${project.rootDir}/$filename")
return if (keystorePropertiesFile.exists()) {
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
keystoreProperties
} else {
null
}
} }

View File

@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".RokidApplication" android:name=".RokidApplication"
@ -18,8 +19,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/Theme.Rokid"
android:theme="@style/Theme.Rokid"> android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@ -30,6 +31,16 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name="com.yuanxuan.rokid.keeplive.KeepLiveService" /> <service android:name="com.yuanxuan.rokid.keeplive.KeepLiveService" />
<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/parovider_path" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@ -1,9 +1,9 @@
package com.yuanxuan.rokid package com.yuanxuan.rokid
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.addCallback import androidx.activity.addCallback
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@ -44,38 +44,23 @@ class MainActivity : AppCompatActivity() {
} }
// /** /**
// * 触摸板事件 * 触摸板事件
// * [KeyEvent.KEYCODE_DPAD_DOWN] 和 [KeyEvent.KEYCODE_DPAD_RIGHT] 同时响应 * [KeyEvent.KEYCODE_DPAD_DOWN] [KeyEvent.KEYCODE_DPAD_RIGHT] 同时响应
// * 所以直接消费掉一个 * 所以直接消费掉一个
// */ */
// override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
// return if (event.action == KeyEvent.ACTION_DOWN) { return if (AppDependencies.deviceServiceManager.instructState.value ==
// when (event.keyCode) { DeviceServiceManager.InstructState.WaitingInstructState
// KeyEvent.KEYCODE_DPAD_DOWN -> { ) {
// true if (event.keyCode == KeyEvent.KEYCODE_BACK) {
// } AppDependencies.deviceServiceManager.quitInstructReceived()
// }
// KeyEvent.KEYCODE_DPAD_UP -> { true
// true } else {
// } super.dispatchKeyEvent(event)
// }
// KeyEvent.KEYCODE_DPAD_LEFT -> { }
// viewModel.onKeyEventDispatched(MainViewModel.KeyEvent.DpadLeft)
// true
// }
//
// KeyEvent.KEYCODE_DPAD_RIGHT -> {
// viewModel.onKeyEventDispatched(MainViewModel.KeyEvent.DpadRight)
// true
// }
//
// else -> super.dispatchKeyEvent(event)
// }
// } else {
// super.dispatchKeyEvent(event)
// }
// }
private fun observer() { private fun observer() {
lifecycleScope.launch { lifecycleScope.launch {

View File

@ -4,6 +4,8 @@ import android.app.Application
import com.yuanxuan.rokid.device.DeviceServiceManager import com.yuanxuan.rokid.device.DeviceServiceManager
import com.yuanxuan.rokid.network.http.ApiRepository import com.yuanxuan.rokid.network.http.ApiRepository
import com.yuanxuan.rokid.network.http.ApiService import com.yuanxuan.rokid.network.http.ApiService
import com.yuanxuan.rokid.network.http.DownloadApiRepository
import com.yuanxuan.rokid.network.http.DownloadRetrofitClient
import com.yuanxuan.rokid.network.http.RetrofitClient import com.yuanxuan.rokid.network.http.RetrofitClient
import com.yuanxuan.rokid.network.websocket.WebSocketManager import com.yuanxuan.rokid.network.websocket.WebSocketManager
import com.yuanxuan.rokid.toast.ToastManager import com.yuanxuan.rokid.toast.ToastManager
@ -36,15 +38,25 @@ object AppDependencies {
provider.provideWebSocketManager() provider.provideWebSocketManager()
} }
val retrofitClient by lazy { private val retrofitClient by lazy {
provider.provideRetrofitClient() provider.provideRetrofitClient()
} }
private val downloadRetrofitClient by lazy {
provider.provideDownloadRetrofitClient()
}
val requestApi by lazy { val requestApi by lazy {
provider.provideApiRepository( provider.provideApiRepository(
apiService = retrofitClient.apiService apiService = retrofitClient.apiService
) )
} }
val downloadApi by lazy {
provider.provideDownloadApiRepository(
apiService = downloadRetrofitClient.apiService
)
}
val toastManager by lazy { val toastManager by lazy {
provider.provideToastManager() provider.provideToastManager()
} }
@ -53,7 +65,9 @@ object AppDependencies {
fun provideDeviceServiceManager(): DeviceServiceManager fun provideDeviceServiceManager(): DeviceServiceManager
fun provideWebSocketManager(): WebSocketManager fun provideWebSocketManager(): WebSocketManager
fun provideRetrofitClient(): RetrofitClient fun provideRetrofitClient(): RetrofitClient
fun provideDownloadRetrofitClient(): DownloadRetrofitClient
fun provideApiRepository(apiService: ApiService): ApiRepository fun provideApiRepository(apiService: ApiService): ApiRepository
fun provideDownloadApiRepository(apiService: ApiService): DownloadApiRepository
fun provideToastManager(): ToastManager fun provideToastManager(): ToastManager
} }

View File

@ -4,6 +4,8 @@ import android.app.Application
import com.yuanxuan.rokid.device.DeviceServiceManager import com.yuanxuan.rokid.device.DeviceServiceManager
import com.yuanxuan.rokid.network.http.ApiRepository import com.yuanxuan.rokid.network.http.ApiRepository
import com.yuanxuan.rokid.network.http.ApiService import com.yuanxuan.rokid.network.http.ApiService
import com.yuanxuan.rokid.network.http.DownloadApiRepository
import com.yuanxuan.rokid.network.http.DownloadRetrofitClient
import com.yuanxuan.rokid.network.http.RetrofitClient import com.yuanxuan.rokid.network.http.RetrofitClient
import com.yuanxuan.rokid.network.websocket.WebSocketManager import com.yuanxuan.rokid.network.websocket.WebSocketManager
import com.yuanxuan.rokid.toast.ToastManager import com.yuanxuan.rokid.toast.ToastManager
@ -26,12 +28,20 @@ class ApplicationDependencyProvider(val context: Application, val scope: Corouti
return RetrofitClient() return RetrofitClient()
} }
override fun provideDownloadRetrofitClient(): DownloadRetrofitClient {
return DownloadRetrofitClient()
}
override fun provideApiRepository(apiService: ApiService): ApiRepository { override fun provideApiRepository(apiService: ApiService): ApiRepository {
return ApiRepository( return ApiRepository(
apiService = apiService apiService = apiService
) )
} }
override fun provideDownloadApiRepository(apiService: ApiService): DownloadApiRepository {
return DownloadApiRepository(apiService = apiService)
}
override fun provideToastManager(): ToastManager { override fun provideToastManager(): ToastManager {
return ToastManager( return ToastManager(
context = context, context = context,

View File

@ -3,7 +3,7 @@ package com.yuanxuan.rokid.extension
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
import android.view.View import android.view.View
import androidx.core.view.isGone import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
@ -31,7 +31,7 @@ fun View.fadeOut(duration: Long) {
.setDuration(duration) .setDuration(duration)
.setListener(object : AnimatorListenerAdapter() { .setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) { override fun onAnimationEnd(animation: Animator) {
this@fadeOut.isGone = true this@fadeOut.isInvisible = true
} }
}) })
} }

View File

@ -0,0 +1,92 @@
package com.yuanxuan.rokid.network
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.yuanxuan.rokid.dependencies.AppDependencies
import timber.log.Timber
import java.io.File
import java.util.concurrent.TimeUnit
class ApkDownloadWorker(appContext: Context, params: WorkerParameters) :
CoroutineWorker(appContext, params) {
companion object {
const val WORE_NAME = "ApkDownloadWorker"
fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<ApkDownloadWorker>()
.setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
//重试策略 一共5次 线性增加间隔时长
.setBackoffCriteria(
backoffPolicy = BackoffPolicy.LINEAR,
backoffDelay = 30,
timeUnit = TimeUnit.SECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
uniqueWorkName = WORE_NAME,
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
request = request
)
}
}
override suspend fun doWork(): Result {
Timber.d("重试次数 $runAttemptCount")
// 重试限制5次
if (runAttemptCount >= 5) {
return Result.failure()
}
AppDependencies.downloadApi.apkDownload("http://192.168.2.83:8000/download/app-release.apk")
.fold(
onSuccess = {
installApkSilent(it)
return Result.success()
},
onFailure = {
return Result.retry()
}
)
}
fun installApkSilent(apkFile: File) {
if (!apkFile.exists()) {
Timber.e("APK 文件不存在,无法安装: ${apkFile.absolutePath}")
return
}
val intent = Intent(Intent.ACTION_VIEW)
val uri: Uri = FileProvider.getUriForFile(
applicationContext,
"${applicationContext.packageName}.fileProvider",
apkFile
)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.setDataAndType(uri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
applicationContext.startActivity(intent)
} catch (e: Exception) {
Timber.e(e, "启动安装程序失败")
}
}
}

View File

@ -6,7 +6,8 @@ import java.net.NetworkInterface
object NetUtils { object NetUtils {
fun getBaseUrl() = "http://${getLocalIPV4address()}.83:8765" fun getBaseUrl() = "http://${getLocalIPV4address()}.70:7070"
// fun getBaseUrl() = "http://${getLocalIPV4address()}.83:8765"
/** /**

View File

@ -3,7 +3,10 @@ package com.yuanxuan.rokid.network.http
import com.yuanxuan.rokid.network.NetUtils import com.yuanxuan.rokid.network.NetUtils
import com.yuanxuan.rokid.network.bean.ApiResponse import com.yuanxuan.rokid.network.bean.ApiResponse
import com.yuanxuan.rokid.network.bean.Test import com.yuanxuan.rokid.network.bean.Test
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Streaming
import retrofit2.http.Url import retrofit2.http.Url
interface ApiService { interface ApiService {
@ -11,4 +14,8 @@ interface ApiService {
@GET @GET
suspend fun test(@Url url: String = "${NetUtils.getBaseUrl()}/test"): ApiResponse<Test> suspend fun test(@Url url: String = "${NetUtils.getBaseUrl()}/test"): ApiResponse<Test>
@Streaming
@GET
suspend fun downloadFiles(@Url fileUrl: String): Response<ResponseBody>
} }

View File

@ -0,0 +1,68 @@
package com.yuanxuan.rokid.network.http
import com.yuanxuan.rokid.dependencies.AppDependencies
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import retrofit2.HttpException
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
class DownloadApiRepository(private val apiService: ApiService) {
suspend fun apkDownload(url: String): Result<File> = withContext(Dispatchers.IO) {
runCatching {
val response = apiService.downloadFiles(url)
if (!response.isSuccessful) throw HttpException(response)
val body = response.body() ?: throw IOException("下载失败:响应成功但响应体为空。")
val file = File(AppDependencies.application.externalCacheDir, "rokid.apk")
val isSuccess = writeResponseBodyToDisk(body, file)
if (!isSuccess) {
throw IOException("将文件写入磁盘时失败。")
}
file
}
}
private fun writeResponseBodyToDisk(body: ResponseBody, file: File): Boolean {
var inputStream: InputStream? = null
var outputStream: OutputStream? = null
try {
val fileReader = ByteArray(102400)
val fileSize = body.contentLength()
var fileSizeDownloaded: Long = 0
inputStream = body.byteStream()
outputStream = FileOutputStream(file)
while (true) {
val read = inputStream.read(fileReader)
if (read == -1) {
break
}
outputStream.write(fileReader, 0, read)
fileSizeDownloaded += read.toLong()
val progress = (fileSizeDownloaded * 100 / fileSize).toInt()
Timber.d("文件下载中... $progress%")
}
outputStream.flush()
Timber.d("文件下载成功: ${file.path}")
return true
} catch (e: IOException) {
Timber.e(e, "写入文件时发生 IO 异常")
return false
} finally {
inputStream?.close()
outputStream?.close()
}
}
}

View File

@ -0,0 +1,38 @@
package com.yuanxuan.rokid.network.http
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
class DownloadRetrofitClient {
private val okHttpClient: OkHttpClient by lazy {
val httpLoggingInterceptor by lazy {
HttpLoggingInterceptor { message ->
Timber.tag("OkHttp").d(message)
}.apply {
level = HttpLoggingInterceptor.Level.BASIC
}
}
OkHttpClient.Builder()
.connectTimeout(15.seconds)
.readTimeout(30.seconds)
.writeTimeout(30.seconds)
.addInterceptor(httpLoggingInterceptor)
.build()
}
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.client(okHttpClient)
.baseUrl("http://localhost/")
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().create()))
.build()
}
val apiService: ApiService by lazy {
retrofit.create(ApiService::class.java)
}
}

View File

@ -27,6 +27,7 @@ class RetrofitClient {
private val retrofit: Retrofit by lazy { private val retrofit: Retrofit by lazy {
Retrofit.Builder() Retrofit.Builder()
.client(okHttpClient) .client(okHttpClient)
.baseUrl("http://localhost/")
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().create())) .addConverterFactory(GsonConverterFactory.create(GsonBuilder().create()))
.build() .build()
} }

View File

@ -11,10 +11,7 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import okhttp3.logging.HttpLoggingInterceptor
import timber.log.Timber import timber.log.Timber
import java.net.Inet4Address
import java.net.NetworkInterface
class OkHttpWebSocketConnection() : WebSocketListener(), WebSocketConnection { class OkHttpWebSocketConnection() : WebSocketListener(), WebSocketConnection {
@ -23,20 +20,12 @@ class OkHttpWebSocketConnection() : WebSocketListener(), WebSocketConnection {
val webSocketConnectionStateFlow = _webSocketConnectionStateFlow.asStateFlow() val webSocketConnectionStateFlow = _webSocketConnectionStateFlow.asStateFlow()
private var client: WebSocket? = null private var client: WebSocket? = null
private val httpLoggingInterceptor by lazy {
HttpLoggingInterceptor { message ->
Timber.tag("OkHttp").d(message)
}.apply {
level = HttpLoggingInterceptor.Level.BODY
}
}
@Synchronized @Synchronized
override fun connect() { override fun connect() {
val okHttpClient = OkHttpClient.Builder() val okHttpClient = OkHttpClient.Builder()
.readTimeout(DEFAULT_SEND_TIMEOUT) .readTimeout(DEFAULT_SEND_TIMEOUT)
.pingInterval(PING_INTERVAL_TIME) .pingInterval(PING_INTERVAL_TIME)
.addInterceptor(httpLoggingInterceptor)
.build() .build()
val request = Request.Builder().url(NetUtils.getBaseUrl()).build() val request = Request.Builder().url(NetUtils.getBaseUrl()).build()
@ -62,6 +51,11 @@ class OkHttpWebSocketConnection() : WebSocketListener(), WebSocketConnection {
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
Timber.d(text)
}
override fun onOpen(webSocket: WebSocket, response: Response) {
Timber.d("websocket onOpen")
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {

View File

@ -11,7 +11,9 @@
android:id="@+id/nav_host_fragment" android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment" android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/lottie_voice_input"
app:defaultNavHost="true" app:defaultNavHost="true"
app:navGraph="@navigation/app_navigation" /> app:navGraph="@navigation/app_navigation" />
@ -57,14 +59,15 @@
<com.airbnb.lottie.LottieAnimationView <com.airbnb.lottie.LottieAnimationView
android:id="@+id/lottie_voice_input" android:id="@+id/lottie_voice_input"
android:layout_width="200dp" android:layout_width="150dp"
android:layout_height="100dp" android:layout_height="50dp"
android:visibility="gone" android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/bottom" app:layout_constraintBottom_toTopOf="@id/bottom"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:lottie_autoPlay="false"
app:lottie_loop="true" app:lottie_loop="true"
app:lottie_rawRes="@raw/lottie_input_voice" app:lottie_rawRes="@raw/voice_white"
tools:visibility="visible" /> tools:visibility="visible" />
<Space <Space

File diff suppressed because one or more lines are too long

View File

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

View File

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

4
keystore.properties Normal file
View File

@ -0,0 +1,4 @@
storeFile=keystore/release_keystore.jks
storePassword=S<+*T;KcbR?yMm3b
keyAlias=key0
keyPassword=]+*7;Vn+zpA=r{KX