diff --git a/README.md b/README.md index 9e54e02..6668072 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,11 @@ - Реализован минимальный GATT transport для обмена mesh-пакетами. - Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram. - Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`. +- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`. - Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`. - Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`. - В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов. +- Обновление приложения выполняется через APK во временном каталоге: проверка версии, скачивание, остановка mesh и запуск системной установки через `Intent`. - При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh. - Публикация APK и сайта автоматизирована через `Makefile`. - Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 33e2964..cd79722 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ + + + + + diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt index dc73e74..98869e8 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt @@ -156,6 +156,14 @@ class MainActivity : AppCompatActivity() { menuInflater.inflate(R.menu.main_menu, menu) setOnMenuItemClickListener { item -> when (item.itemId) { + R.id.menu_map -> { + startActivity(Intent(this@MainActivity, PacketMapActivity::class.java)) + true + } + R.id.menu_packets -> { + startActivity(Intent(this@MainActivity, PacketLogActivity::class.java)) + true + } R.id.menu_settings -> { startActivity(Intent(this@MainActivity, SettingsActivity::class.java)) true diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt index c0038d6..1a6ebbe 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt @@ -2,6 +2,7 @@ package pro.nnnteam.nnnet import android.os.Build import android.os.Bundle +import android.view.View import android.widget.EditText import android.widget.ImageButton import android.widget.TextView @@ -16,6 +17,7 @@ import pro.nnnteam.nnnet.data.MeshRepository import pro.nnnteam.nnnet.data.ProfileEntity import pro.nnnteam.nnnet.mesh.MeshServiceContract import pro.nnnteam.nnnet.update.UpdateInfo +import pro.nnnteam.nnnet.update.UpdateInstaller import pro.nnnteam.nnnet.update.UpdateManager import android.content.BroadcastReceiver import android.content.Intent @@ -33,6 +35,7 @@ class SettingsActivity : AppCompatActivity() { private lateinit var resultUsernameText: TextView private lateinit var resultDescriptionText: TextView private lateinit var resultPeerIdText: TextView + private lateinit var updateProgressText: TextView private var receiverRegistered = false @@ -77,6 +80,7 @@ class SettingsActivity : AppCompatActivity() { resultUsernameText = findViewById(R.id.resultUsernameText) resultDescriptionText = findViewById(R.id.resultDescriptionText) resultPeerIdText = findViewById(R.id.resultPeerIdText) + updateProgressText = findViewById(R.id.updateProgressText) val autoUpdateSwitch = findViewById(R.id.autoUpdateSwitch) val versionText = findViewById(R.id.versionText) @@ -198,14 +202,17 @@ class SettingsActivity : AppCompatActivity() { private fun checkForUpdates() { lifecycleScope.launch { + showUpdateProgress(getString(R.string.update_checking)) val updateInfo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchUpdateInfo() } if (updateInfo == null) { + hideUpdateProgress() Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show() return@launch } if (updateInfo.versionCode > currentVersionCode()) { showUpdateDialog(updateInfo) } else { + hideUpdateProgress() Toast.makeText(this@SettingsActivity, R.string.latest_version_installed, Toast.LENGTH_SHORT).show() } } @@ -228,14 +235,40 @@ class SettingsActivity : AppCompatActivity() { } ) .setPositiveButton(R.string.download_update) { _, _ -> - val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath) - startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))) + downloadAndInstallUpdate(updateInfo) } .setNegativeButton(R.string.later, null) .show() } } + private fun downloadAndInstallUpdate(updateInfo: UpdateInfo) { + lifecycleScope.launch { + showUpdateProgress(getString(R.string.update_downloading)) + val apkFile = UpdateInstaller.downloadToTempFile(this@SettingsActivity, updateInfo) + if (apkFile == null) { + hideUpdateProgress() + Toast.makeText(this@SettingsActivity, R.string.update_download_failed, Toast.LENGTH_SHORT).show() + return@launch + } + + showUpdateProgress(getString(R.string.update_installing)) + val started = UpdateInstaller.installDownloadedApk(this@SettingsActivity, apkFile) + if (!started) { + hideUpdateProgress() + } + } + } + + private fun showUpdateProgress(message: String) { + updateProgressText.visibility = View.VISIBLE + updateProgressText.text = message + } + + private fun hideUpdateProgress() { + updateProgressText.visibility = View.GONE + } + private fun currentVersionCode(): Int { val packageInfo = packageManager.getPackageInfo(packageName, 0) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/update/UpdateInstaller.kt b/android/app/src/main/java/pro/nnnteam/nnnet/update/UpdateInstaller.kt new file mode 100644 index 0000000..c70b74d --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/update/UpdateInstaller.kt @@ -0,0 +1,72 @@ +package pro.nnnteam.nnnet.update + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import pro.nnnteam.nnnet.R +import pro.nnnteam.nnnet.mesh.MeshForegroundService +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +object UpdateInstaller { + suspend fun downloadToTempFile(context: Context, updateInfo: UpdateInfo): File? { + return withContext(Dispatchers.IO) { + runCatching { + val updatesDir = File(context.cacheDir, "updates").apply { mkdirs() } + val apkFile = File(updatesDir, "nnnet-update-${updateInfo.versionName}.apk") + val connection = URL(UpdateManager.buildDownloadUrl(updateInfo.apkPath)).openConnection() as HttpURLConnection + connection.connectTimeout = 15_000 + connection.readTimeout = 60_000 + connection.inputStream.use { input -> + apkFile.outputStream().use { output -> + input.copyTo(output) + } + } + apkFile + }.getOrNull() + } + } + + fun installDownloadedApk(context: Context, apkFile: File, stopMesh: Boolean = true): Boolean { + if (!apkFile.exists()) return false + if (stopMesh) { + MeshForegroundService.stop(context) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) { + val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + Toast.makeText(context, R.string.allow_unknown_apps, Toast.LENGTH_LONG).show() + return false + } + + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apkFile + ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, R.string.installer_not_found, Toast.LENGTH_SHORT).show() + false + } + } +} diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index ec8d97d..7549e29 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -233,6 +233,14 @@ android:layout_marginTop="16dp" android:text="@string/check_updates" app:cornerRadius="18dp" /> + + diff --git a/android/app/src/main/res/menu/main_menu.xml b/android/app/src/main/res/menu/main_menu.xml index 4ac1bb6..b4d5d60 100644 --- a/android/app/src/main/res/menu/main_menu.xml +++ b/android/app/src/main/res/menu/main_menu.xml @@ -1,5 +1,11 @@ + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 67566de..f0187e2 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -21,9 +21,15 @@ Введите ID устройства Профиль не найден в локальном каталоге сети Не удалось проверить обновления + Не удалось скачать обновление У вас уже установлена последняя версия Доступна версия %1$s. Скачать обновление + Проверяем версию… + Скачиваем APK во временный каталог… + Останавливаем сеть и запускаем установку… + Разрешите установку из этого приложения, затем повторите обновление. + Системный установщик не найден Позже Назад Сообщений пока нет. Напишите первым. diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..ae3321b --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2817c0b..b61839d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,10 +14,11 @@ - Главный экран показывает список чатов в стиле Telegram. - Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`. - Слева в шапке показывается общее количество известных устройств в mesh. -- Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран. +- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран. - Отправка сообщений доступна только из экрана конкретного диалога. - В настройках пользователь редактирует свой профиль и ищет другие профили по `username`. - В настройках доступны режим карты сети и экран журнала пакетов. +- Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`. ## Топология сети - Выделенный сервер или хост для работы mesh не нужен.