Add in-app update installer flow and menu tools
Some checks failed
Android CI / build (push) Has been cancelled
Some checks failed
Android CI / build (push) Has been cancelled
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
6
android/app/src/main/res/xml/file_paths.xml
Normal file
6
android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
@@ -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 не нужен.
|
||||||
|
|||||||
Reference in New Issue
Block a user