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