diff --git a/.gitignore b/.gitignore index a8b0d1d..c37ccdd 100644 --- a/.gitignore +++ b/.gitignore @@ -23,13 +23,10 @@ misc.xml deploymentTargetDropDown.xml render.experimental.xml -# Keystore files -*.jks -*.keystore - # Google Services (e.g. APIs or Firebase) google-services.json # Android Profiling *.hprof +app/release diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2062a41..0bf228d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,8 +1,14 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) } +val keystores: Map = + mapOf("release" to loadKeystoreProperties("keystore.properties")) + android { namespace = "com.yuanxuan.rokid" compileSdk { @@ -19,8 +25,23 @@ android { 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 { + debug { + signingConfig = signingConfigs.getByName("release") + } release { + signingConfig = signingConfigs.getByName("release") isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -59,4 +80,16 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) 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 + } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3400255..670ced2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + + android:theme="@style/Theme.Rokid" + android:usesCleartextTraffic="true"> + + + + \ No newline at end of file diff --git a/app/src/main/java/com/yuanxuan/rokid/MainActivity.kt b/app/src/main/java/com/yuanxuan/rokid/MainActivity.kt index ba1f598..47a266b 100644 --- a/app/src/main/java/com/yuanxuan/rokid/MainActivity.kt +++ b/app/src/main/java/com/yuanxuan/rokid/MainActivity.kt @@ -1,9 +1,9 @@ package com.yuanxuan.rokid import android.os.Bundle +import android.view.KeyEvent import androidx.activity.addCallback import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -44,38 +44,23 @@ class MainActivity : AppCompatActivity() { } -// /** -// * 触摸板事件 -// * [KeyEvent.KEYCODE_DPAD_DOWN] 和 [KeyEvent.KEYCODE_DPAD_RIGHT] 同时响应 -// * 所以直接消费掉一个 -// */ -// override fun dispatchKeyEvent(event: KeyEvent): Boolean { -// return if (event.action == KeyEvent.ACTION_DOWN) { -// when (event.keyCode) { -// KeyEvent.KEYCODE_DPAD_DOWN -> { -// true -// } -// -// KeyEvent.KEYCODE_DPAD_UP -> { -// true -// } -// -// 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) -// } -// } + /** + * 触摸板事件 + * [KeyEvent.KEYCODE_DPAD_DOWN] 和 [KeyEvent.KEYCODE_DPAD_RIGHT] 同时响应 + * 所以直接消费掉一个 + */ + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + return if (AppDependencies.deviceServiceManager.instructState.value == + DeviceServiceManager.InstructState.WaitingInstructState + ) { + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + AppDependencies.deviceServiceManager.quitInstructReceived() + } + true + } else { + super.dispatchKeyEvent(event) + } + } private fun observer() { lifecycleScope.launch { diff --git a/app/src/main/java/com/yuanxuan/rokid/dependencies/AppDependencies.kt b/app/src/main/java/com/yuanxuan/rokid/dependencies/AppDependencies.kt index adbf251..9c1a704 100644 --- a/app/src/main/java/com/yuanxuan/rokid/dependencies/AppDependencies.kt +++ b/app/src/main/java/com/yuanxuan/rokid/dependencies/AppDependencies.kt @@ -4,6 +4,8 @@ import android.app.Application import com.yuanxuan.rokid.device.DeviceServiceManager import com.yuanxuan.rokid.network.http.ApiRepository 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.websocket.WebSocketManager import com.yuanxuan.rokid.toast.ToastManager @@ -36,15 +38,25 @@ object AppDependencies { provider.provideWebSocketManager() } - val retrofitClient by lazy { + private val retrofitClient by lazy { provider.provideRetrofitClient() } + private val downloadRetrofitClient by lazy { + provider.provideDownloadRetrofitClient() + } + val requestApi by lazy { provider.provideApiRepository( apiService = retrofitClient.apiService ) } + + val downloadApi by lazy { + provider.provideDownloadApiRepository( + apiService = downloadRetrofitClient.apiService + ) + } val toastManager by lazy { provider.provideToastManager() } @@ -53,7 +65,9 @@ object AppDependencies { fun provideDeviceServiceManager(): DeviceServiceManager fun provideWebSocketManager(): WebSocketManager fun provideRetrofitClient(): RetrofitClient + fun provideDownloadRetrofitClient(): DownloadRetrofitClient fun provideApiRepository(apiService: ApiService): ApiRepository + fun provideDownloadApiRepository(apiService: ApiService): DownloadApiRepository fun provideToastManager(): ToastManager } diff --git a/app/src/main/java/com/yuanxuan/rokid/dependencies/ApplicationDependencyProvider.kt b/app/src/main/java/com/yuanxuan/rokid/dependencies/ApplicationDependencyProvider.kt index 3e4c5ef..69c54eb 100644 --- a/app/src/main/java/com/yuanxuan/rokid/dependencies/ApplicationDependencyProvider.kt +++ b/app/src/main/java/com/yuanxuan/rokid/dependencies/ApplicationDependencyProvider.kt @@ -4,6 +4,8 @@ import android.app.Application import com.yuanxuan.rokid.device.DeviceServiceManager import com.yuanxuan.rokid.network.http.ApiRepository 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.websocket.WebSocketManager import com.yuanxuan.rokid.toast.ToastManager @@ -26,12 +28,20 @@ class ApplicationDependencyProvider(val context: Application, val scope: Corouti return RetrofitClient() } + override fun provideDownloadRetrofitClient(): DownloadRetrofitClient { + return DownloadRetrofitClient() + } + override fun provideApiRepository(apiService: ApiService): ApiRepository { return ApiRepository( apiService = apiService ) } + override fun provideDownloadApiRepository(apiService: ApiService): DownloadApiRepository { + return DownloadApiRepository(apiService = apiService) + } + override fun provideToastManager(): ToastManager { return ToastManager( context = context, diff --git a/app/src/main/java/com/yuanxuan/rokid/extension/ViewExtensions.kt b/app/src/main/java/com/yuanxuan/rokid/extension/ViewExtensions.kt index a37a1da..7d46921 100644 --- a/app/src/main/java/com/yuanxuan/rokid/extension/ViewExtensions.kt +++ b/app/src/main/java/com/yuanxuan/rokid/extension/ViewExtensions.kt @@ -3,7 +3,7 @@ package com.yuanxuan.rokid.extension import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.view.View -import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible @@ -31,7 +31,7 @@ fun View.fadeOut(duration: Long) { .setDuration(duration) .setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - this@fadeOut.isGone = true + this@fadeOut.isInvisible = true } }) } \ No newline at end of file diff --git a/app/src/main/java/com/yuanxuan/rokid/network/ApkDownloadWorker.kt b/app/src/main/java/com/yuanxuan/rokid/network/ApkDownloadWorker.kt new file mode 100644 index 0000000..1e1ff2e --- /dev/null +++ b/app/src/main/java/com/yuanxuan/rokid/network/ApkDownloadWorker.kt @@ -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() + .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, "启动安装程序失败") + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/yuanxuan/rokid/network/NetUtils.kt b/app/src/main/java/com/yuanxuan/rokid/network/NetUtils.kt index 50f1f48..2f7fcfb 100644 --- a/app/src/main/java/com/yuanxuan/rokid/network/NetUtils.kt +++ b/app/src/main/java/com/yuanxuan/rokid/network/NetUtils.kt @@ -6,7 +6,8 @@ import java.net.NetworkInterface object NetUtils { - fun getBaseUrl() = "http://${getLocalIPV4address()}.83:8765" + fun getBaseUrl() = "http://${getLocalIPV4address()}.70:7070" +// fun getBaseUrl() = "http://${getLocalIPV4address()}.83:8765" /** diff --git a/app/src/main/java/com/yuanxuan/rokid/network/http/ApiService.kt b/app/src/main/java/com/yuanxuan/rokid/network/http/ApiService.kt index 616b9bb..862ac32 100644 --- a/app/src/main/java/com/yuanxuan/rokid/network/http/ApiService.kt +++ b/app/src/main/java/com/yuanxuan/rokid/network/http/ApiService.kt @@ -3,7 +3,10 @@ package com.yuanxuan.rokid.network.http import com.yuanxuan.rokid.network.NetUtils import com.yuanxuan.rokid.network.bean.ApiResponse import com.yuanxuan.rokid.network.bean.Test +import okhttp3.ResponseBody +import retrofit2.Response import retrofit2.http.GET +import retrofit2.http.Streaming import retrofit2.http.Url interface ApiService { @@ -11,4 +14,8 @@ interface ApiService { @GET suspend fun test(@Url url: String = "${NetUtils.getBaseUrl()}/test"): ApiResponse + @Streaming + @GET + suspend fun downloadFiles(@Url fileUrl: String): Response + } \ No newline at end of file diff --git a/app/src/main/java/com/yuanxuan/rokid/network/http/DownloadApiRepository.kt b/app/src/main/java/com/yuanxuan/rokid/network/http/DownloadApiRepository.kt new file mode 100644 index 0000000..80f3575 --- /dev/null +++ b/app/src/main/java/com/yuanxuan/rokid/network/http/DownloadApiRepository.kt @@ -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 = 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() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/yuanxuan/rokid/network/http/DownloadRetrofitClient.kt b/app/src/main/java/com/yuanxuan/rokid/network/http/DownloadRetrofitClient.kt new file mode 100644 index 0000000..045ff67 --- /dev/null +++ b/app/src/main/java/com/yuanxuan/rokid/network/http/DownloadRetrofitClient.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yuanxuan/rokid/network/http/RetrofitClient.kt b/app/src/main/java/com/yuanxuan/rokid/network/http/RetrofitClient.kt index 2d3b3f6..6bb8540 100644 --- a/app/src/main/java/com/yuanxuan/rokid/network/http/RetrofitClient.kt +++ b/app/src/main/java/com/yuanxuan/rokid/network/http/RetrofitClient.kt @@ -27,6 +27,7 @@ class RetrofitClient { private val retrofit: Retrofit by lazy { Retrofit.Builder() .client(okHttpClient) + .baseUrl("http://localhost/") .addConverterFactory(GsonConverterFactory.create(GsonBuilder().create())) .build() } diff --git a/app/src/main/java/com/yuanxuan/rokid/network/websocket/OkHttpWebSocketConnection.kt b/app/src/main/java/com/yuanxuan/rokid/network/websocket/OkHttpWebSocketConnection.kt index 66928ea..7055049 100644 --- a/app/src/main/java/com/yuanxuan/rokid/network/websocket/OkHttpWebSocketConnection.kt +++ b/app/src/main/java/com/yuanxuan/rokid/network/websocket/OkHttpWebSocketConnection.kt @@ -11,10 +11,7 @@ import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener -import okhttp3.logging.HttpLoggingInterceptor import timber.log.Timber -import java.net.Inet4Address -import java.net.NetworkInterface class OkHttpWebSocketConnection() : WebSocketListener(), WebSocketConnection { @@ -23,20 +20,12 @@ class OkHttpWebSocketConnection() : WebSocketListener(), WebSocketConnection { val webSocketConnectionStateFlow = _webSocketConnectionStateFlow.asStateFlow() private var client: WebSocket? = null - private val httpLoggingInterceptor by lazy { - HttpLoggingInterceptor { message -> - Timber.tag("OkHttp").d(message) - }.apply { - level = HttpLoggingInterceptor.Level.BODY - } - } @Synchronized override fun connect() { val okHttpClient = OkHttpClient.Builder() .readTimeout(DEFAULT_SEND_TIMEOUT) .pingInterval(PING_INTERVAL_TIME) - .addInterceptor(httpLoggingInterceptor) .build() val request = Request.Builder().url(NetUtils.getBaseUrl()).build() @@ -62,6 +51,11 @@ class OkHttpWebSocketConnection() : WebSocketListener(), WebSocketConnection { } 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?) { diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 6fb9f21..5110b8d 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -11,7 +11,9 @@ android:id="@+id/nav_host_fragment" android:name="androidx.navigation.fragment.NavHostFragment" 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:navGraph="@navigation/app_navigation" /> @@ -57,14 +59,15 @@ - - - 192.168.2.83 - - - \ No newline at end of file diff --git a/app/src/main/res/xml/parovider_path.xml b/app/src/main/res/xml/parovider_path.xml new file mode 100644 index 0000000..12dce3b --- /dev/null +++ b/app/src/main/res/xml/parovider_path.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/keystore.properties b/keystore.properties new file mode 100644 index 0000000..3c3f425 --- /dev/null +++ b/keystore.properties @@ -0,0 +1,4 @@ +storeFile=keystore/release_keystore.jks +storePassword=S<+*T;KcbR?yMm3b +keyAlias=key0 +keyPassword=]+*7;Vn+zpA=r{KX \ No newline at end of file