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-пакетами.
- Есть 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`.

View File

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

View File

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

View File

@@ -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<SwitchMaterial>(R.id.autoUpdateSwitch)
val versionText = findViewById<TextView>(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) {

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:text="@string/check_updates"
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>
</ScrollView>
</LinearLayout>

View File

@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<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
android:id="@+id/menu_settings"
android:title="@string/settings_title" />

View File

@@ -21,9 +21,15 @@
<string name="peer_id_required">Введите ID устройства</string>
<string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
<string name="update_check_failed">Не удалось проверить обновления</string>
<string name="update_download_failed">Не удалось скачать обновление</string>
<string name="latest_version_installed">У вас уже установлена последняя версия</string>
<string name="update_available_message">Доступна версия %1$s.</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="back">Назад</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.
- Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`.
- Слева в шапке показывается общее количество известных устройств в mesh.
- Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран.
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран.
- Отправка сообщений доступна только из экрана конкретного диалога.
- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`.
- В настройках доступны режим карты сети и экран журнала пакетов.
- Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`.
## Топология сети
- Выделенный сервер или хост для работы mesh не нужен.