@@ -2,8 +2,10 @@ package com.better.nothing.music.vizualizer.ui
22
33import android.app.Application
44import android.content.Context
5+ import android.content.Intent
56import android.graphics.Bitmap
67import android.content.pm.PackageManager
8+ import android.net.Uri
79import android.util.Base64
810import android.util.Log
911import android.widget.Toast
@@ -21,6 +23,7 @@ import com.google.firebase.auth.EmailAuthProvider
2123import com.google.firebase.auth.GoogleAuthProvider
2224import com.google.firebase.auth.AuthCredential
2325import androidx.compose.ui.graphics.Color
26+ import androidx.core.content.FileProvider
2427import kotlinx.coroutines.Dispatchers
2528import kotlinx.coroutines.delay
2629import kotlinx.coroutines.flow.*
@@ -29,6 +32,10 @@ import kotlinx.coroutines.withContext
2932import kotlinx.coroutines.tasks.await
3033import org.json.JSONObject
3134import rikka.shizuku.Shizuku
35+ import java.io.File
36+ import java.io.FileOutputStream
37+ import java.net.HttpURLConnection
38+ import java.net.URL
3239import kotlin.math.pow
3340import kotlin.math.sqrt
3441
@@ -148,8 +155,67 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
148155 _appUpdateStatus .value = AppUpdateStatus .UpToDate
149156 }
150157
151- fun downloadAndInstallUpdate (apkUrl : String , version : String ) {
152- // Mock
158+ fun downloadAndInstallUpdate (apkUrl : String , versionName : String ) {
159+ viewModelScope.launch(Dispatchers .IO ) {
160+ try {
161+ val url = URL (apkUrl)
162+ val connection = url.openConnection() as HttpURLConnection
163+ connection.connectTimeout = 10000
164+ connection.readTimeout = 30000
165+
166+ if (connection.responseCode == HttpURLConnection .HTTP_OK ) {
167+ val fileLength = connection.contentLength
168+ val destinationFile = File (ctx.externalCacheDir, " update_$versionName .apk" )
169+
170+ connection.inputStream.use { input ->
171+ FileOutputStream (destinationFile).use { output ->
172+ val buffer = ByteArray (8192 )
173+ var bytesRead: Int
174+ var totalBytesRead = 0L
175+
176+ while (input.read(buffer).also { bytesRead = it } != - 1 ) {
177+ output.write(buffer, 0 , bytesRead)
178+ totalBytesRead + = bytesRead
179+ if (fileLength > 0 ) {
180+ val progress = totalBytesRead.toFloat() / fileLength.toFloat()
181+ _appUpdateStatus .value = AppUpdateStatus .Downloading (progress)
182+ }
183+ }
184+ }
185+ }
186+
187+ withContext(Dispatchers .Main ) {
188+ installApk(destinationFile)
189+ }
190+ } else {
191+ withContext(Dispatchers .Main ) {
192+ Toast .makeText(ctx, " Download failed: ${connection.responseCode} " , Toast .LENGTH_SHORT ).show()
193+ _appUpdateStatus .value = AppUpdateStatus .Error (" Download failed" )
194+ }
195+ }
196+ } catch (e: Exception ) {
197+ Log .e(" MainViewModel" , " Download failed" , e)
198+ withContext(Dispatchers .Main ) {
199+ Toast .makeText(ctx, " Download error: ${e.message} " , Toast .LENGTH_SHORT ).show()
200+ _appUpdateStatus .value = AppUpdateStatus .Error (e.message ? : " Unknown error" )
201+ }
202+ }
203+ }
204+ }
205+
206+ private fun installApk (file : File ) {
207+ try {
208+ val uri = FileProvider .getUriForFile(ctx, " ${ctx.packageName} .fileprovider" , file)
209+ val intent = Intent (Intent .ACTION_VIEW ).apply {
210+ setDataAndType(uri, " application/vnd.android.package-archive" )
211+ addFlags(Intent .FLAG_GRANT_READ_URI_PERMISSION )
212+ addFlags(Intent .FLAG_ACTIVITY_NEW_TASK )
213+ }
214+ ctx.startActivity(intent)
215+ } catch (e: Exception ) {
216+ Log .e(" MainViewModel" , " Installation failed" , e)
217+ Toast .makeText(ctx, " Installation failed: ${e.message} " , Toast .LENGTH_SHORT ).show()
218+ }
153219 }
154220
155221 private val _isShowingLeaderboard = MutableStateFlow (false )
@@ -194,9 +260,154 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
194260 }
195261 }
196262
197- fun checkRemoteConfigVersion () { TODO () }
198- fun importZonesConfig (uri : android.net.Uri ) { /* Mock */ }
199- fun updateZonesConfig (url : String = "") { /* Mock */ }
263+ fun checkRemoteConfigVersion () {
264+ viewModelScope.launch(Dispatchers .IO ) {
265+ try {
266+ val url =
267+ URL (" https://raw.githubusercontent.com/Aleks-Levet/better-nothing-music-visualizer/main/zones.config?t=${System .currentTimeMillis()} " )
268+ val connection = url.openConnection() as HttpURLConnection
269+ connection.useCaches = false
270+ connection.connectTimeout = 5000
271+ connection.readTimeout = 5000
272+
273+ if (connection.responseCode == HttpURLConnection .HTTP_OK ) {
274+ val content = connection.inputStream.bufferedReader().use { it.readText() }
275+ val json = JSONObject (content)
276+ val remoteVersion = json.optString(" version" , " Unknown" )
277+ _remoteConfigVersion .value = remoteVersion
278+ }
279+ } catch (e: Exception ) {
280+ Log .e(" MainViewModel" , " Failed to check remote version" , e)
281+ }
282+ }
283+ }
284+ fun importZonesConfig (uri : Uri ) {
285+ _configUpdateStatus .value = ConfigUpdateStatus .Updating
286+ viewModelScope.launch {
287+ announcementRepository.getLatestAnnouncement().collect { announcement ->
288+ _latestAnnouncement .value = announcement
289+ if (announcement != null ) {
290+ val sharedPrefs = ctx.getSharedPreferences(" viz_prefs" , Context .MODE_PRIVATE )
291+ val lastSeenId = sharedPrefs.getString(" last_seen_announcement_id" , " " )
292+ if (announcement.id.toString() != lastSeenId) {
293+ _showAnnouncementModal .value = true
294+ }
295+ }
296+ }
297+ }
298+
299+ viewModelScope.launch {
300+ try {
301+ val success = withContext(Dispatchers .IO ) {
302+ val content = ctx.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
303+ if (content == null ) return @withContext false
304+
305+ // Basic validation
306+ JSONObject (content)
307+
308+ val file = File (ctx.filesDir, " zones.config" )
309+ file.writeText(content)
310+
311+ // Refresh presets (file IO)
312+ refreshPresetsInternal()
313+
314+ val newVersion = AudioCaptureService .loadZonesConfigVersion(ctx)
315+ _configVersion .value = newVersion
316+ _remoteConfigVersion .value = null // Clear remote version since we are on local
317+
318+ // Force running service to reload its config from disk
319+ MainActivity .serviceStatic?.reloadConfig()
320+ true
321+ }
322+
323+ if (success) {
324+ _configUpdateStatus .value = ConfigUpdateStatus .Success (ctx.getString(R .string.config_import_success))
325+ } else {
326+ _configUpdateStatus .value = ConfigUpdateStatus .Error (ctx.getString(R .string.config_import_error))
327+ }
328+ } catch (e: Exception ) {
329+ _configUpdateStatus .value = ConfigUpdateStatus .Error (ctx.getString(R .string.config_error_importing, e.message))
330+ }
331+ }
332+ }
333+ fun updateZonesConfig () {
334+ // 1. Set loading state immediately on Main Thread
335+ _configUpdateStatus .value = ConfigUpdateStatus .Updating
336+
337+ viewModelScope.launch {
338+ announcementRepository.getLatestAnnouncement().collect { announcement ->
339+ _latestAnnouncement .value = announcement
340+ if (announcement != null ) {
341+ val sharedPrefs = ctx.getSharedPreferences(" viz_prefs" , Context .MODE_PRIVATE )
342+ val lastSeenId = sharedPrefs.getString(" last_seen_announcement_id" , " " )
343+ if (announcement.id.toString() != lastSeenId) {
344+ _showAnnouncementModal .value = true
345+ }
346+ }
347+ }
348+ }
349+
350+ viewModelScope.launch {
351+ try {
352+ // 2. Perform network/download on IO Thread
353+ val success = withContext(Dispatchers .IO ) {
354+ performUpdateAction()
355+ }
356+
357+ // 3. Back on Main Thread automatically after withContext
358+ if (success) {
359+ _configUpdateStatus .value = ConfigUpdateStatus .Success (ctx.getString(R .string.config_update_success))
360+ }
361+ // Errors are handled inside performUpdateAction setting the status directly now,
362+ // or we could return Result object. To keep it simple with existing code:
363+ } catch (e: Exception ) {
364+ // Catch unexpected errors
365+ _configUpdateStatus .value = ConfigUpdateStatus .Error (ctx.getString(R .string.config_error_updating, e.message))
366+ }
367+ }
368+ }
369+ private suspend fun performUpdateAction (): Boolean {
370+ // This runs on Dispatchers.IO (called from withContext(IO) above)
371+ return try {
372+ val url = URL (" https://raw.githubusercontent.com/Aleks-Levet/better-nothing-music-visualizer/main/zones.config?t=${System .currentTimeMillis()} " )
373+ val connection = withContext(Dispatchers .IO ) {
374+ url.openConnection()
375+ } as HttpURLConnection
376+ connection.useCaches = false
377+ connection.connectTimeout = 10000
378+ connection.readTimeout = 10000
379+
380+ if (connection.responseCode == HttpURLConnection .HTTP_OK ) {
381+ val content = connection.inputStream.bufferedReader().use { it.readText() }
382+ // Basic validation
383+ JSONObject (content)
384+
385+ val file = File (ctx.filesDir, " zones.config" )
386+ file.writeText(content)
387+
388+ // Refresh presets (file IO)
389+ refreshPresetsInternal()
390+
391+ val newVersion = AudioCaptureService .loadZonesConfigVersion(ctx)
392+ _configVersion .value = newVersion
393+ _remoteConfigVersion .value = newVersion
394+
395+ // Force running service to reload its config from disk
396+ MainActivity .serviceStatic?.reloadConfig()
397+ true
398+ } else {
399+ withContext(Dispatchers .Main ) {
400+ _configUpdateStatus .value = ConfigUpdateStatus .Error (ctx.getString(R .string.config_download_error, connection.responseCode))
401+ }
402+ false
403+ }
404+ } catch (e: Exception ) {
405+ withContext(Dispatchers .Main ) {
406+ _configUpdateStatus .value = ConfigUpdateStatus .Error (ctx.getString(R .string.config_error_updating, e.message))
407+ }
408+ false
409+ }
410+ }
200411 fun updateProfilePicture (uri : android.net.Uri ) {
201412 val uid = _userId .value ? : return
202413 viewModelScope.launch {
@@ -353,7 +564,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
353564 }
354565
355566 val _thanksMessage = MutableStateFlow <String ?>(null )
356- val thanksMessage = _thanksMessage .asStateFlow()
357567 val thanksQueue = mutableListOf<String >()
358568
359569 fun dismissThanksMessage () {
0 commit comments