Add in-app update installer flow and menu tools
Some checks failed
Android CI / build (push) Has been cancelled

This commit is contained in:
dom4k
2026-03-17 02:43:13 +00:00
parent 909d1462f7
commit c158fd63b6
10 changed files with 156 additions and 3 deletions

View File

@@ -13,9 +13,11 @@
- Реализован минимальный GATT transport для обмена mesh-пакетами. - Реализован минимальный GATT transport для обмена mesh-пакетами.
- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram. - Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`. - Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`.
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`. - Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`.
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`. - Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`.
- В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов. - В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов.
- Обновление приложения выполняется через APK во временном каталоге: проверка версии, скачивание, остановка mesh и запуск системной установки через `Intent`.
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh. - При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
- Публикация APK и сайта автоматизирована через `Makefile`. - Публикация APK и сайта автоматизирована через `Makefile`.
- Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`. - Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`.

View File

@@ -17,6 +17,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_CONNECTED_DEVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -53,6 +54,16 @@
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="connectedDevice" /> android:foregroundServiceType="connectedDevice" />
<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> </application>
</manifest> </manifest>

View File

@@ -156,6 +156,14 @@ class MainActivity : AppCompatActivity() {
menuInflater.inflate(R.menu.main_menu, menu) menuInflater.inflate(R.menu.main_menu, menu)
setOnMenuItemClickListener { item -> setOnMenuItemClickListener { item ->
when (item.itemId) { 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 -> { R.id.menu_settings -> {
startActivity(Intent(this@MainActivity, SettingsActivity::class.java)) startActivity(Intent(this@MainActivity, SettingsActivity::class.java))
true true

View File

@@ -2,6 +2,7 @@ package pro.nnnteam.nnnet
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
@@ -16,6 +17,7 @@ import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.data.ProfileEntity import pro.nnnteam.nnnet.data.ProfileEntity
import pro.nnnteam.nnnet.mesh.MeshServiceContract import pro.nnnteam.nnnet.mesh.MeshServiceContract
import pro.nnnteam.nnnet.update.UpdateInfo import pro.nnnteam.nnnet.update.UpdateInfo
import pro.nnnteam.nnnet.update.UpdateInstaller
import pro.nnnteam.nnnet.update.UpdateManager import pro.nnnteam.nnnet.update.UpdateManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Intent import android.content.Intent
@@ -33,6 +35,7 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var resultUsernameText: TextView private lateinit var resultUsernameText: TextView
private lateinit var resultDescriptionText: TextView private lateinit var resultDescriptionText: TextView
private lateinit var resultPeerIdText: TextView private lateinit var resultPeerIdText: TextView
private lateinit var updateProgressText: TextView
private var receiverRegistered = false private var receiverRegistered = false
@@ -77,6 +80,7 @@ class SettingsActivity : AppCompatActivity() {
resultUsernameText = findViewById(R.id.resultUsernameText) resultUsernameText = findViewById(R.id.resultUsernameText)
resultDescriptionText = findViewById(R.id.resultDescriptionText) resultDescriptionText = findViewById(R.id.resultDescriptionText)
resultPeerIdText = findViewById(R.id.resultPeerIdText) resultPeerIdText = findViewById(R.id.resultPeerIdText)
updateProgressText = findViewById(R.id.updateProgressText)
val autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch) val autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch)
val versionText = findViewById<TextView>(R.id.versionText) val versionText = findViewById<TextView>(R.id.versionText)
@@ -198,14 +202,17 @@ class SettingsActivity : AppCompatActivity() {
private fun checkForUpdates() { private fun checkForUpdates() {
lifecycleScope.launch { lifecycleScope.launch {
showUpdateProgress(getString(R.string.update_checking))
val updateInfo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchUpdateInfo() } val updateInfo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchUpdateInfo() }
if (updateInfo == null) { if (updateInfo == null) {
hideUpdateProgress()
Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show() Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show()
return@launch return@launch
} }
if (updateInfo.versionCode > currentVersionCode()) { if (updateInfo.versionCode > currentVersionCode()) {
showUpdateDialog(updateInfo) showUpdateDialog(updateInfo)
} else { } else {
hideUpdateProgress()
Toast.makeText(this@SettingsActivity, R.string.latest_version_installed, Toast.LENGTH_SHORT).show() 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) { _, _ -> .setPositiveButton(R.string.download_update) { _, _ ->
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath) downloadAndInstallUpdate(updateInfo)
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
} }
.setNegativeButton(R.string.later, null) .setNegativeButton(R.string.later, null)
.show() .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 { private fun currentVersionCode(): Int {
val packageInfo = packageManager.getPackageInfo(packageName, 0) val packageInfo = packageManager.getPackageInfo(packageName, 0)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {

View File

@@ -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
}
}
}

View File

@@ -233,6 +233,14 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:text="@string/check_updates" android:text="@string/check_updates"
app:cornerRadius="18dp" /> app:cornerRadius="18dp" />
<TextView
android:id="@+id/updateProgressText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="@color/secondary_text"
android:visibility="gone" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
</LinearLayout> </LinearLayout>

View File

@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"> <menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_map"
android:title="@string/map_mode_title" />
<item
android:id="@+id/menu_packets"
android:title="@string/packet_log_title" />
<item <item
android:id="@+id/menu_settings" android:id="@+id/menu_settings"
android:title="@string/settings_title" /> android:title="@string/settings_title" />

View File

@@ -21,9 +21,15 @@
<string name="peer_id_required">Введите ID устройства</string> <string name="peer_id_required">Введите ID устройства</string>
<string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string> <string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
<string name="update_check_failed">Не удалось проверить обновления</string> <string name="update_check_failed">Не удалось проверить обновления</string>
<string name="update_download_failed">Не удалось скачать обновление</string>
<string name="latest_version_installed">У вас уже установлена последняя версия</string> <string name="latest_version_installed">У вас уже установлена последняя версия</string>
<string name="update_available_message">Доступна версия %1$s.</string> <string name="update_available_message">Доступна версия %1$s.</string>
<string name="download_update">Скачать обновление</string> <string name="download_update">Скачать обновление</string>
<string name="update_checking">Проверяем версию…</string>
<string name="update_downloading">Скачиваем APK во временный каталог…</string>
<string name="update_installing">Останавливаем сеть и запускаем установку…</string>
<string name="allow_unknown_apps">Разрешите установку из этого приложения, затем повторите обновление.</string>
<string name="installer_not_found">Системный установщик не найден</string>
<string name="later">Позже</string> <string name="later">Позже</string>
<string name="back">Назад</string> <string name="back">Назад</string>
<string name="no_messages">Сообщений пока нет. Напишите первым.</string> <string name="no_messages">Сообщений пока нет. Напишите первым.</string>

View File

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

View File

@@ -14,10 +14,11 @@
- Главный экран показывает список чатов в стиле Telegram. - Главный экран показывает список чатов в стиле Telegram.
- Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`. - Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`.
- Слева в шапке показывается общее количество известных устройств в mesh. - Слева в шапке показывается общее количество известных устройств в mesh.
- Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран. - В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран.
- Отправка сообщений доступна только из экрана конкретного диалога. - Отправка сообщений доступна только из экрана конкретного диалога.
- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`. - В настройках пользователь редактирует свой профиль и ищет другие профили по `username`.
- В настройках доступны режим карты сети и экран журнала пакетов. - В настройках доступны режим карты сети и экран журнала пакетов.
- Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`.
## Топология сети ## Топология сети
- Выделенный сервер или хост для работы mesh не нужен. - Выделенный сервер или хост для работы mesh не нужен.