diff --git a/README.md b/README.md index 676ffe0..48aec05 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,11 @@ ## Текущее состояние - BLE discovery + advertising работают. - Реализован минимальный GATT transport для обмена mesh-пакетами. -- Есть foreground service, Room-хранилище, ACK/retry очередь и базовый Telegram-подобный UI. -- Реализованы список чатов, окно диалога, вкладка настроек, ручная проверка обновлений и опциональная автопроверка через `version.json`. +- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram. +- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`. - При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh. - Публикация APK и сайта автоматизирована через `Makefile`. +- Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`. ## Стек - Android приложение: **Kotlin** @@ -42,7 +43,7 @@ 3. **Messaging Layer** - личные сообщения; - - список чатов и окно диалога; + - список чатов и отдельный экран диалога; - статусы доставки (queued/sent/relayed/delivered). 4. **Data Layer** @@ -78,6 +79,7 @@ - [x] Добавить защиту от дубликатов по `messageId` (in-memory cache, базово). - [x] Реализовать mesh-forwarding с ограничением TTL (routing action layer, базово). - [x] Добавить список чатов и базовый UI окна сообщений. +- [x] Перенести настройки в меню `три точки` и убрать debug-лог из пользовательского интерфейса. - [x] Подключить Room и базовую схему хранения. - [x] Добавить логирование сети и debug-экран маршрутов. - [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента. @@ -107,3 +109,9 @@ ## Ближайший следующий шаг Добавить профили пользователей, шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами. + +## Ограничения сети +- Выделенный хост для NNNet не нужен: сеть строится как P2P mesh между устройствами. +- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты. +- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android. +- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fe5cce9..12540f8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -5,11 +5,11 @@ plugins { } android { - namespace = "com.schoolmesh.messenger" + namespace = "pro.nnnteam.nnnet" compileSdk = 34 defaultConfig { - applicationId = "com.schoolmesh.messenger" + applicationId = "pro.nnnteam.nnnet" minSdk = 26 targetSdk = 34 versionCode = 3 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 12cafb2..3bdb227 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,7 +24,14 @@ android:label="@string/app_name" android:roundIcon="@android:drawable/sym_def_app_icon" android:supportsRtl="true" - android:theme="@style/Theme.SchoolMeshMessenger"> + android:theme="@style/Theme.NNNet"> + + diff --git a/android/app/src/main/java/com/schoolmesh/messenger/MainActivity.kt b/android/app/src/main/java/com/schoolmesh/messenger/MainActivity.kt deleted file mode 100644 index 4b235d0..0000000 --- a/android/app/src/main/java/com/schoolmesh/messenger/MainActivity.kt +++ /dev/null @@ -1,487 +0,0 @@ -package com.schoolmesh.messenger - -import android.Manifest -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.widget.ArrayAdapter -import android.widget.Button -import android.widget.EditText -import android.widget.ListView -import android.widget.TextView -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.google.android.material.switchmaterial.SwitchMaterial -import com.schoolmesh.messenger.data.ChatSummary -import com.schoolmesh.messenger.data.MeshDatabase -import com.schoolmesh.messenger.data.MeshRepository -import com.schoolmesh.messenger.mesh.MeshForegroundService -import com.schoolmesh.messenger.mesh.MeshServiceContract -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.net.HttpURLConnection -import java.net.URL -import java.text.SimpleDateFormat -import java.util.ArrayDeque -import java.util.Date -import java.util.Locale - -class MainActivity : AppCompatActivity() { - private lateinit var repository: MeshRepository - private lateinit var statusText: TextView - private lateinit var peersText: TextView - private lateinit var logsText: TextView - private lateinit var activeChatTitle: TextView - private lateinit var targetInput: EditText - private lateinit var messageInput: EditText - private lateinit var chatListView: ListView - private lateinit var messageListView: ListView - private lateinit var chatsScreen: android.view.View - private lateinit var settingsScreen: android.view.View - private lateinit var autoUpdateSwitch: SwitchMaterial - - private val peers = linkedSetOf() - private val logs = ArrayDeque() - private val chatSummaries = mutableListOf() - private val chatItems = mutableListOf() - private val messageItems = mutableListOf() - - private lateinit var chatAdapter: ArrayAdapter - private lateinit var messageAdapter: ArrayAdapter - - private var activePeerId: String? = null - private var pendingSend: PendingSend? = null - private var pendingStartRequested = false - - private val prefs by lazy { - getSharedPreferences("nnnet_settings", Context.MODE_PRIVATE) - } - - private val meshEventReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action != MeshServiceContract.ACTION_EVENT) return - val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return - val value = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_VALUE) ?: return - - when (eventType) { - MeshServiceContract.EVENT_STATUS -> updateStatus(value) - MeshServiceContract.EVENT_PEER -> addPeer(value) - MeshServiceContract.EVENT_LOG -> appendLog(value) - MeshServiceContract.EVENT_MESSAGES_CHANGED -> { - refreshChats() - refreshMessages() - } - } - } - } - - private val permissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { result -> - val allGranted = result.values.all { it } - if (allGranted) { - ensureBluetoothEnabledAndContinue() - } else { - updateStatus("Нет BLE-разрешений") - appendLog("Permissions denied by user") - Toast.makeText(this, "Permissions denied", Toast.LENGTH_SHORT).show() - } - } - - private val enableBluetoothLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - if (bluetoothAdapter()?.isEnabled == true) { - continueAfterBluetoothReady() - } else { - updateStatus("Bluetooth is disabled") - appendLog("Bluetooth enable request denied") - Toast.makeText(this, "Bluetooth is required", Toast.LENGTH_SHORT).show() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - - val database = MeshDatabase.getInstance(applicationContext) - repository = MeshRepository(database.messageDao(), database.outboundQueueDao()) - - statusText = findViewById(R.id.statusText) - peersText = findViewById(R.id.peersText) - logsText = findViewById(R.id.logsText) - activeChatTitle = findViewById(R.id.activeChatTitle) - targetInput = findViewById(R.id.targetInput) - messageInput = findViewById(R.id.messageInput) - chatListView = findViewById(R.id.chatListView) - messageListView = findViewById(R.id.messageListView) - chatsScreen = findViewById(R.id.chatsScreen) - settingsScreen = findViewById(R.id.settingsScreen) - autoUpdateSwitch = findViewById(R.id.autoUpdateSwitch) - - chatAdapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, chatItems) - messageAdapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, messageItems) - chatListView.adapter = chatAdapter - messageListView.adapter = messageAdapter - - findViewById(R.id.btnTabChats).setOnClickListener { showChats() } - findViewById(R.id.btnTabSettings).setOnClickListener { showSettings() } - findViewById(R.id.btnStartMesh).setOnClickListener { - pendingStartRequested = true - ensurePermissionsAndMaybeStart() - } - findViewById(R.id.btnStopMesh).setOnClickListener { - MeshForegroundService.stop(this) - updateStatus("Mesh stopped") - appendLog("Mesh service stop requested") - } - findViewById(R.id.btnSendMessage).setOnClickListener { - enqueueMessageFromUi() - } - findViewById(R.id.btnCheckUpdates).setOnClickListener { - checkForUpdates(manual = true) - } - - chatListView.setOnItemClickListener { _, _, position, _ -> - val chat = chatSummaries[position] - activePeerId = chat.peerId - targetInput.setText(chat.peerId) - activeChatTitle.text = chat.peerId - refreshMessages() - } - - autoUpdateSwitch.isChecked = prefs.getBoolean(KEY_AUTO_UPDATE, false) - autoUpdateSwitch.setOnCheckedChangeListener { _, isChecked -> - prefs.edit().putBoolean(KEY_AUTO_UPDATE, isChecked).apply() - appendLog("Auto update set to $isChecked") - } - - renderPeers() - renderLogs() - refreshChats() - refreshMessages() - } - - override fun onStart() { - super.onStart() - registerMeshReceiver() - refreshChats() - refreshMessages() - if (autoUpdateSwitch.isChecked) { - checkForUpdates(manual = false) - } - } - - override fun onStop() { - unregisterReceiver(meshEventReceiver) - super.onStop() - } - - private fun showChats() { - chatsScreen.visibility = android.view.View.VISIBLE - settingsScreen.visibility = android.view.View.GONE - } - - private fun showSettings() { - chatsScreen.visibility = android.view.View.GONE - settingsScreen.visibility = android.view.View.VISIBLE - } - - private fun registerMeshReceiver() { - val filter = IntentFilter(MeshServiceContract.ACTION_EVENT) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(meshEventReceiver, filter, RECEIVER_NOT_EXPORTED) - } else { - @Suppress("DEPRECATION") - registerReceiver(meshEventReceiver, filter) - } - } - - private fun enqueueMessageFromUi() { - val targetId = targetInput.text.toString().trim() - val body = messageInput.text.toString().trim() - if (targetId.isEmpty() || body.isEmpty()) { - Toast.makeText(this, "Target and message are required", Toast.LENGTH_SHORT).show() - return - } - - pendingSend = PendingSend(targetId, body) - pendingStartRequested = true - ensurePermissionsAndMaybeStart() - appendLog("Message queued for $targetId") - messageInput.text?.clear() - } - - private fun ensurePermissionsAndMaybeStart() { - val missing = requiredPermissions().filter { permission -> - ContextCompat.checkSelfPermission(this, permission) != android.content.pm.PackageManager.PERMISSION_GRANTED - } - if (missing.isEmpty()) { - ensureBluetoothEnabledAndContinue() - } else { - permissionLauncher.launch(missing.toTypedArray()) - } - } - - private fun ensureBluetoothEnabledAndContinue() { - val adapter = bluetoothAdapter() - if (adapter == null) { - updateStatus("Bluetooth adapter unavailable") - appendLog("Bluetooth adapter unavailable") - return - } - if (adapter.isEnabled) { - continueAfterBluetoothReady() - } else { - val enableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) - enableBluetoothLauncher.launch(enableIntent) - } - } - - private fun continueAfterBluetoothReady() { - if (pendingStartRequested) { - startMesh() - pendingStartRequested = false - } - pendingSend?.let { - activePeerId = it.targetId - targetInput.setText(it.targetId) - activeChatTitle.text = it.targetId - MeshForegroundService.sendMessage(this, it.targetId, it.body) - pendingSend = null - } - refreshChats() - refreshMessages() - } - - private fun requiredPermissions(): List { - val permissions = mutableListOf() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - permissions += Manifest.permission.BLUETOOTH_SCAN - permissions += Manifest.permission.BLUETOOTH_CONNECT - permissions += Manifest.permission.BLUETOOTH_ADVERTISE - } else { - permissions += Manifest.permission.ACCESS_FINE_LOCATION - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissions += Manifest.permission.POST_NOTIFICATIONS - } - return permissions - } - - private fun startMesh() { - MeshForegroundService.start(this) - updateStatus("Foreground service starting") - appendLog("Mesh service start requested") - } - - private fun bluetoothAdapter(): BluetoothAdapter? { - val manager = getSystemService(BluetoothManager::class.java) - return manager?.adapter - } - - private fun refreshChats() { - lifecycleScope.launch { - val chats = repository.chatSummaries() - chatSummaries.clear() - chatSummaries.addAll(chats) - chatItems.clear() - chatItems.addAll( - chats.map { chat -> - val time = timestampFormatter.format(Date(chat.lastTimestamp)) - "${chat.peerId}\n${chat.lastBody}\n$time · ${chat.lastStatus}" - } - ) - chatAdapter.notifyDataSetChanged() - if (activePeerId == null && chats.isNotEmpty()) { - activePeerId = chats.first().peerId - targetInput.setText(activePeerId) - activeChatTitle.text = activePeerId - } - } - } - - private fun refreshMessages() { - val peerId = activePeerId ?: targetInput.text.toString().trim() - if (peerId.isEmpty()) { - messageItems.clear() - messageItems.add("Select a chat or enter a peer id") - messageAdapter.notifyDataSetChanged() - return - } - - lifecycleScope.launch { - val messages = repository.messagesForPeer(peerId) - messageItems.clear() - if (messages.isEmpty()) { - messageItems.add("No messages with $peerId yet") - } else { - messageItems.addAll( - messages - .asReversed() - .map { message -> - val time = timestampFormatter.format(Date(message.createdAt)) - val bubble = if (message.direction == MeshRepository.DIRECTION_OUTGOING) "You" else message.senderId - "[$time] $bubble\n${message.body}\n${message.status}" - } - ) - } - messageAdapter.notifyDataSetChanged() - } - } - - private fun checkForUpdates(manual: Boolean) { - lifecycleScope.launch { - val updateInfo = withContext(Dispatchers.IO) { - runCatching { - val connection = URL(UPDATE_METADATA_URL).openConnection() as HttpURLConnection - connection.connectTimeout = 5_000 - connection.readTimeout = 5_000 - connection.inputStream.bufferedReader().use { reader -> - val json = JSONObject(reader.readText()) - UpdateInfo( - versionCode = json.getInt("versionCode"), - versionName = json.getString("versionName"), - apkPath = json.getString("apkPath"), - releaseNotesTitle = json.optString("releaseNotesTitle", "Что нового"), - releaseNotesPath = json.optString("releaseNotesPath", "") - ) - } - }.getOrNull() - } - - if (updateInfo == null) { - if (manual) { - Toast.makeText(this@MainActivity, "Failed to check updates", Toast.LENGTH_SHORT).show() - } - appendLog("Update check failed") - return@launch - } - - if (updateInfo.versionCode > currentVersionCode()) { - appendLog("Update found: ${updateInfo.versionName}") - showUpdateDialog(updateInfo) - } else if (manual) { - Toast.makeText(this@MainActivity, "You already have the latest version", Toast.LENGTH_SHORT).show() - } - } - } - - private fun showUpdateDialog(updateInfo: UpdateInfo) { - lifecycleScope.launch { - val releaseNotes = withContext(Dispatchers.IO) { - fetchReleaseNotes(updateInfo.releaseNotesPath) - } - - AlertDialog.Builder(this@MainActivity) - .setTitle(updateInfo.releaseNotesTitle) - .setMessage( - buildString { - append("Version ${updateInfo.versionName} is available.") - if (!releaseNotes.isNullOrBlank()) { - append("\n\n") - append(releaseNotes.trim()) - } - } - ) - .setPositiveButton("Open download") { _, _ -> - val url = if (updateInfo.apkPath.startsWith("http")) { - updateInfo.apkPath - } else { - "$UPDATE_BASE_URL/${updateInfo.apkPath.trimStart('/')}" - } - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - } - .setNegativeButton("Later", null) - .show() - } - } - - private fun fetchReleaseNotes(path: String): String? { - if (path.isBlank()) return null - val url = if (path.startsWith("http")) path else "$UPDATE_BASE_URL/${path.trimStart('/')}" - return runCatching { - val connection = URL(url).openConnection() as HttpURLConnection - connection.connectTimeout = 5_000 - connection.readTimeout = 5_000 - connection.inputStream.bufferedReader().use { it.readText() } - }.getOrNull() - } - - private fun currentVersionCode(): Int { - val packageInfo = packageManager.getPackageInfo(packageName, 0) - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - packageInfo.longVersionCode.toInt() - } else { - @Suppress("DEPRECATION") - packageInfo.versionCode - } - } - - private fun updateStatus(text: String) { - statusText.text = text - } - - private fun addPeer(address: String) { - if (peers.add(address)) { - renderPeers() - } - } - - private fun appendLog(message: String) { - if (logs.size >= MAX_LOG_ENTRIES) { - logs.removeFirst() - } - logs.addLast(message) - renderLogs() - } - - private fun renderPeers() { - peersText.text = if (peers.isEmpty()) { - "Nearby peers will appear here" - } else { - "Peers online: ${peers.joinToString(separator = ", ")}" - } - } - - private fun renderLogs() { - logsText.text = if (logs.isEmpty()) { - "Log is empty" - } else { - logs.joinToString(separator = "\n") - } - } - - companion object { - private const val KEY_AUTO_UPDATE = "auto_update" - private const val MAX_LOG_ENTRIES = 30 - private const val UPDATE_BASE_URL = "https://net.nnn-team.pro" - private const val UPDATE_METADATA_URL = "$UPDATE_BASE_URL/assets/meta/version.json" - private val timestampFormatter = SimpleDateFormat("HH:mm", Locale.US) - } - - private data class PendingSend( - val targetId: String, - val body: String - ) - - private data class UpdateInfo( - val versionCode: Int, - val versionName: String, - val apkPath: String, - val releaseNotesTitle: String, - val releaseNotesPath: String - ) -} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt new file mode 100644 index 0000000..9702571 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt @@ -0,0 +1,218 @@ +package pro.nnnteam.nnnet + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.Bundle +import android.view.View +import android.widget.EditText +import android.widget.ImageButton +import android.widget.ListView +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import pro.nnnteam.nnnet.data.MeshDatabase +import pro.nnnteam.nnnet.data.MeshRepository +import pro.nnnteam.nnnet.data.MessageEntity +import pro.nnnteam.nnnet.mesh.MeshForegroundService +import pro.nnnteam.nnnet.mesh.MeshServiceContract +import pro.nnnteam.nnnet.ui.MessageListAdapter + +class ChatActivity : AppCompatActivity() { + private lateinit var repository: MeshRepository + private lateinit var titleText: TextView + private lateinit var subtitleText: TextView + private lateinit var messageInput: EditText + private lateinit var emptyStateText: TextView + private lateinit var messagesListView: ListView + + private val messages = mutableListOf() + private lateinit var adapter: MessageListAdapter + + private var receiverRegistered = false + private var pendingStartRequested = false + private var pendingBody: String? = null + private lateinit var peerId: String + + private val meshEventReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != MeshServiceContract.ACTION_EVENT) return + val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return + val value = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_VALUE) ?: return + when (eventType) { + MeshServiceContract.EVENT_STATUS -> subtitleText.text = value + MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshMessages() + MeshServiceContract.EVENT_PEER -> if (value == peerId) { + subtitleText.text = getString(R.string.peer_nearby) + } + } + } + } + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + if (result.values.all { it }) { + ensureBluetoothEnabledAndContinue() + } else { + Toast.makeText(this, R.string.permissions_denied, Toast.LENGTH_SHORT).show() + } + } + + private val enableBluetoothLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (bluetoothAdapter()?.isEnabled == true) { + continueAfterBluetoothReady() + } else { + Toast.makeText(this, R.string.bluetooth_required, Toast.LENGTH_SHORT).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_chat) + + peerId = intent.getStringExtra(EXTRA_PEER_ID)?.trim().orEmpty() + if (peerId.isEmpty()) { + finish() + return + } + + val database = MeshDatabase.getInstance(applicationContext) + repository = MeshRepository(database.messageDao(), database.outboundQueueDao()) + + titleText = findViewById(R.id.chatTitleText) + subtitleText = findViewById(R.id.chatSubtitleText) + messageInput = findViewById(R.id.messageInput) + emptyStateText = findViewById(R.id.emptyStateText) + messagesListView = findViewById(R.id.messageListView) + + titleText.text = peerId + subtitleText.text = getString(R.string.chat_waiting_status) + + adapter = MessageListAdapter(this, messages) + messagesListView.adapter = adapter + + findViewById(R.id.backButton).setOnClickListener { finish() } + findViewById(R.id.sendButton).setOnClickListener { sendMessage() } + + refreshMessages() + } + + override fun onStart() { + super.onStart() + registerMeshReceiver() + refreshMessages() + } + + override fun onStop() { + if (receiverRegistered) { + unregisterReceiver(meshEventReceiver) + receiverRegistered = false + } + super.onStop() + } + + private fun registerMeshReceiver() { + if (receiverRegistered) return + val filter = IntentFilter(MeshServiceContract.ACTION_EVENT) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(meshEventReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + @Suppress("DEPRECATION") + registerReceiver(meshEventReceiver, filter) + } + receiverRegistered = true + } + + private fun sendMessage() { + val body = messageInput.text.toString().trim() + if (body.isEmpty()) { + Toast.makeText(this, R.string.message_required, Toast.LENGTH_SHORT).show() + return + } + pendingBody = body + pendingStartRequested = true + ensurePermissionsAndMaybeStart() + } + + private fun ensurePermissionsAndMaybeStart() { + val missing = requiredPermissions().filter { permission -> + ContextCompat.checkSelfPermission(this, permission) != android.content.pm.PackageManager.PERMISSION_GRANTED + } + if (missing.isEmpty()) { + ensureBluetoothEnabledAndContinue() + } else { + permissionLauncher.launch(missing.toTypedArray()) + } + } + + private fun ensureBluetoothEnabledAndContinue() { + val adapter = bluetoothAdapter() + if (adapter == null) { + Toast.makeText(this, R.string.bluetooth_unavailable, Toast.LENGTH_SHORT).show() + return + } + if (adapter.isEnabled) { + continueAfterBluetoothReady() + } else { + enableBluetoothLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) + } + } + + private fun continueAfterBluetoothReady() { + if (pendingStartRequested) { + MeshForegroundService.start(this) + pendingStartRequested = false + } + val body = pendingBody ?: return + MeshForegroundService.sendMessage(this, peerId, body) + subtitleText.text = getString(R.string.message_sending) + messageInput.text?.clear() + pendingBody = null + } + + private fun refreshMessages() { + lifecycleScope.launch { + val loadedMessages = repository.messagesForPeer(peerId).asReversed() + messages.clear() + messages.addAll(loadedMessages) + adapter.notifyDataSetChanged() + emptyStateText.visibility = if (loadedMessages.isEmpty()) View.VISIBLE else View.GONE + } + } + + private fun bluetoothAdapter(): BluetoothAdapter? { + val manager = getSystemService(BluetoothManager::class.java) + return manager?.adapter + } + + private fun requiredPermissions(): List { + val permissions = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissions += Manifest.permission.BLUETOOTH_SCAN + permissions += Manifest.permission.BLUETOOTH_CONNECT + permissions += Manifest.permission.BLUETOOTH_ADVERTISE + } else { + permissions += Manifest.permission.ACCESS_FINE_LOCATION + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions += Manifest.permission.POST_NOTIFICATIONS + } + return permissions + } + + companion object { + const val EXTRA_PEER_ID = "peer_id" + } +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt new file mode 100644 index 0000000..466db7f --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt @@ -0,0 +1,356 @@ +package pro.nnnteam.nnnet + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.Bundle +import android.view.View +import android.widget.EditText +import android.widget.ImageButton +import android.widget.ListView +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.PopupMenu +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import pro.nnnteam.nnnet.data.ChatSummary +import pro.nnnteam.nnnet.data.MeshDatabase +import pro.nnnteam.nnnet.data.MeshRepository +import pro.nnnteam.nnnet.mesh.MeshForegroundService +import pro.nnnteam.nnnet.mesh.MeshServiceContract +import pro.nnnteam.nnnet.ui.ChatListAdapter +import pro.nnnteam.nnnet.update.UpdateInfo +import pro.nnnteam.nnnet.update.UpdateManager +import java.util.Locale + +class MainActivity : AppCompatActivity() { + private lateinit var repository: MeshRepository + private lateinit var deviceCountText: TextView + private lateinit var statusBadge: View + private lateinit var statusBadgeText: TextView + private lateinit var emptyStateText: TextView + private lateinit var chatListView: ListView + + private val peers = linkedSetOf() + private val chatSummaries = mutableListOf() + private lateinit var chatAdapter: ChatListAdapter + + private var receiverRegistered = false + private var pendingStartRequested = false + private var meshEnabled = false + + private val prefs by lazy { + getSharedPreferences(UpdateManager.PREFS_NAME, Context.MODE_PRIVATE) + } + + private val meshEventReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != MeshServiceContract.ACTION_EVENT) return + val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return + val value = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_VALUE) ?: return + when (eventType) { + MeshServiceContract.EVENT_STATUS -> updateMeshStatus(value) + MeshServiceContract.EVENT_PEER -> addPeer(value) + MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshChats() + } + } + } + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + val allGranted = result.values.all { it } + if (allGranted) { + ensureBluetoothEnabledAndContinue() + } else { + Toast.makeText(this, R.string.permissions_denied, Toast.LENGTH_SHORT).show() + } + } + + private val enableBluetoothLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (bluetoothAdapter()?.isEnabled == true) { + continueAfterBluetoothReady() + } else { + Toast.makeText(this, R.string.bluetooth_required, Toast.LENGTH_SHORT).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + val database = MeshDatabase.getInstance(applicationContext) + repository = MeshRepository(database.messageDao(), database.outboundQueueDao()) + + deviceCountText = findViewById(R.id.deviceCountText) + statusBadge = findViewById(R.id.statusBadge) + statusBadgeText = findViewById(R.id.statusBadgeText) + emptyStateText = findViewById(R.id.emptyStateText) + chatListView = findViewById(R.id.chatListView) + + chatAdapter = ChatListAdapter(this, chatSummaries) + chatListView.adapter = chatAdapter + chatListView.setOnItemClickListener { _, _, position, _ -> + openChat(chatSummaries[position].peerId) + } + + statusBadge.setOnClickListener { toggleMesh() } + findViewById(R.id.menuButton).setOnClickListener { showMenu(it) } + findViewById(R.id.newChatButton).setOnClickListener { showNewChatDialog() } + + renderDeviceCount() + renderStatusBadge() + refreshChats() + } + + override fun onStart() { + super.onStart() + registerMeshReceiver() + refreshChats() + if (prefs.getBoolean(UpdateManager.KEY_AUTO_UPDATE, false)) { + checkForUpdates(manual = false) + } + } + + override fun onStop() { + if (receiverRegistered) { + unregisterReceiver(meshEventReceiver) + receiverRegistered = false + } + super.onStop() + } + + private fun registerMeshReceiver() { + if (receiverRegistered) return + val filter = IntentFilter(MeshServiceContract.ACTION_EVENT) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(meshEventReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + @Suppress("DEPRECATION") + registerReceiver(meshEventReceiver, filter) + } + receiverRegistered = true + } + + private fun showMenu(anchor: View) { + PopupMenu(this, anchor).apply { + menuInflater.inflate(R.menu.main_menu, menu) + setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.menu_settings -> { + startActivity(Intent(this@MainActivity, SettingsActivity::class.java)) + true + } + else -> false + } + } + show() + } + } + + private fun showNewChatDialog() { + val input = EditText(this).apply { + hint = getString(R.string.hint_peer_id) + setSingleLine() + setPadding(48, 32, 48, 32) + } + + AlertDialog.Builder(this) + .setTitle(R.string.new_chat_title) + .setView(input) + .setPositiveButton(R.string.open_chat) { _, _ -> + val peerId = input.text.toString().trim() + if (peerId.isNotEmpty()) { + openChat(peerId) + } else { + Toast.makeText(this, R.string.peer_id_required, Toast.LENGTH_SHORT).show() + } + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun openChat(peerId: String) { + startActivity(Intent(this, ChatActivity::class.java).putExtra(ChatActivity.EXTRA_PEER_ID, peerId)) + } + + private fun toggleMesh() { + if (meshEnabled) { + MeshForegroundService.stop(this) + meshEnabled = false + peers.clear() + renderStatusBadge() + renderDeviceCount() + } else { + pendingStartRequested = true + ensurePermissionsAndMaybeStart() + } + } + + private fun ensurePermissionsAndMaybeStart() { + val missing = requiredPermissions().filter { permission -> + ContextCompat.checkSelfPermission(this, permission) != android.content.pm.PackageManager.PERMISSION_GRANTED + } + if (missing.isEmpty()) { + ensureBluetoothEnabledAndContinue() + } else { + permissionLauncher.launch(missing.toTypedArray()) + } + } + + private fun ensureBluetoothEnabledAndContinue() { + val adapter = bluetoothAdapter() + if (adapter == null) { + Toast.makeText(this, R.string.bluetooth_unavailable, Toast.LENGTH_SHORT).show() + return + } + if (adapter.isEnabled) { + continueAfterBluetoothReady() + } else { + enableBluetoothLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) + } + } + + private fun continueAfterBluetoothReady() { + if (pendingStartRequested) { + MeshForegroundService.start(this) + meshEnabled = true + pendingStartRequested = false + renderStatusBadge() + renderDeviceCount() + } + } + + private fun refreshChats() { + lifecycleScope.launch { + val chats = repository.chatSummaries() + chatSummaries.clear() + chatSummaries.addAll(chats) + chatAdapter.notifyDataSetChanged() + emptyStateText.visibility = if (chats.isEmpty()) View.VISIBLE else View.GONE + } + } + + private fun addPeer(peerId: String) { + if (peers.add(peerId)) { + renderDeviceCount() + } + } + + private fun updateMeshStatus(status: String) { + val normalized = status.lowercase(Locale.getDefault()) + if (normalized.contains("останов")) { + meshEnabled = false + peers.clear() + } else if ( + normalized.contains("актив") || + normalized.contains("запуска") || + normalized.contains("в сети") || + normalized.contains("присутствие") || + normalized.contains("сообщение") + ) { + meshEnabled = true + } + renderStatusBadge() + renderDeviceCount() + } + + private fun renderStatusBadge() { + statusBadgeText.text = getString(if (meshEnabled) R.string.status_online else R.string.status_offline) + statusBadge.setBackgroundResource( + if (meshEnabled) R.drawable.bg_status_online else R.drawable.bg_status_offline + ) + } + + private fun renderDeviceCount() { + val totalDevices = if (meshEnabled) peers.size + 1 else 1 + deviceCountText.text = getString(R.string.total_devices, totalDevices) + } + + private fun checkForUpdates(manual: Boolean) { + lifecycleScope.launch { + val updateInfo = withContext(Dispatchers.IO) { UpdateManager.fetchUpdateInfo() } + if (updateInfo == null) { + if (manual) { + Toast.makeText(this@MainActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show() + } + return@launch + } + + if (updateInfo.versionCode > currentVersionCode()) { + showUpdateDialog(updateInfo) + } else if (manual) { + Toast.makeText(this@MainActivity, R.string.latest_version_installed, Toast.LENGTH_SHORT).show() + } + } + } + + private fun showUpdateDialog(updateInfo: UpdateInfo) { + lifecycleScope.launch { + val releaseNotes = withContext(Dispatchers.IO) { + UpdateManager.fetchReleaseNotes(updateInfo.releaseNotesPath) + } + AlertDialog.Builder(this@MainActivity) + .setTitle(updateInfo.releaseNotesTitle) + .setMessage( + buildString { + append(getString(R.string.update_available_message, updateInfo.versionName)) + if (!releaseNotes.isNullOrBlank()) { + append("\n\n") + append(releaseNotes.trim()) + } + } + ) + .setPositiveButton(R.string.download_update) { _, _ -> + val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath) + startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))) + } + .setNegativeButton(R.string.later, null) + .show() + } + } + + private fun currentVersionCode(): Int { + val packageInfo = packageManager.getPackageInfo(packageName, 0) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode.toInt() + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode + } + } + + private fun bluetoothAdapter(): BluetoothAdapter? { + val manager = getSystemService(BluetoothManager::class.java) + return manager?.adapter + } + + private fun requiredPermissions(): List { + val permissions = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissions += Manifest.permission.BLUETOOTH_SCAN + permissions += Manifest.permission.BLUETOOTH_CONNECT + permissions += Manifest.permission.BLUETOOTH_ADVERTISE + } else { + permissions += Manifest.permission.ACCESS_FINE_LOCATION + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions += Manifest.permission.POST_NOTIFICATIONS + } + return permissions + } +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt new file mode 100644 index 0000000..d5eb135 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt @@ -0,0 +1,98 @@ +package pro.nnnteam.nnnet + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.widget.ImageButton +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.google.android.material.switchmaterial.SwitchMaterial +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import pro.nnnteam.nnnet.update.UpdateInfo +import pro.nnnteam.nnnet.update.UpdateManager + +class SettingsActivity : AppCompatActivity() { + private val prefs by lazy { + getSharedPreferences(UpdateManager.PREFS_NAME, MODE_PRIVATE) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + + findViewById(R.id.backButton).setOnClickListener { finish() } + + val autoUpdateSwitch = findViewById(R.id.autoUpdateSwitch) + val versionText = findViewById(R.id.versionText) + autoUpdateSwitch.isChecked = prefs.getBoolean(UpdateManager.KEY_AUTO_UPDATE, false) + autoUpdateSwitch.setOnCheckedChangeListener { _, isChecked -> + prefs.edit().putBoolean(UpdateManager.KEY_AUTO_UPDATE, isChecked).apply() + } + + versionText.text = getString( + R.string.current_version, + packageManager.getPackageInfo(packageName, 0).versionName, + currentVersionCode() + ) + + findViewById(R.id.checkUpdatesButton).setOnClickListener { + checkForUpdates() + } + } + + private fun checkForUpdates() { + lifecycleScope.launch { + val updateInfo = withContext(Dispatchers.IO) { UpdateManager.fetchUpdateInfo() } + if (updateInfo == null) { + Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show() + return@launch + } + if (updateInfo.versionCode > currentVersionCode()) { + showUpdateDialog(updateInfo) + } else { + Toast.makeText(this@SettingsActivity, R.string.latest_version_installed, Toast.LENGTH_SHORT).show() + } + } + } + + private fun showUpdateDialog(updateInfo: UpdateInfo) { + lifecycleScope.launch { + val releaseNotes = withContext(Dispatchers.IO) { + UpdateManager.fetchReleaseNotes(updateInfo.releaseNotesPath) + } + AlertDialog.Builder(this@SettingsActivity) + .setTitle(updateInfo.releaseNotesTitle) + .setMessage( + buildString { + append(getString(R.string.update_available_message, updateInfo.versionName)) + if (!releaseNotes.isNullOrBlank()) { + append("\n\n") + append(releaseNotes.trim()) + } + } + ) + .setPositiveButton(R.string.download_update) { _, _ -> + val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath) + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + .setNegativeButton(R.string.later, null) + .show() + } + } + + private fun currentVersionCode(): Int { + val packageInfo = packageManager.getPackageInfo(packageName, 0) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode.toInt() + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode + } + } +} diff --git a/android/app/src/main/java/com/schoolmesh/messenger/data/ChatSummary.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/ChatSummary.kt similarity index 80% rename from android/app/src/main/java/com/schoolmesh/messenger/data/ChatSummary.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/data/ChatSummary.kt index 1a2a7f4..2c99125 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/data/ChatSummary.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/ChatSummary.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.data +package pro.nnnteam.nnnet.data data class ChatSummary( val peerId: String, diff --git a/android/app/src/main/java/com/schoolmesh/messenger/data/MeshDatabase.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt similarity index 95% rename from android/app/src/main/java/com/schoolmesh/messenger/data/MeshDatabase.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt index 404fc0c..6a6e984 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/data/MeshDatabase.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.data +package pro.nnnteam.nnnet.data import android.content.Context import androidx.room.Database diff --git a/android/app/src/main/java/com/schoolmesh/messenger/data/MeshRepository.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt similarity index 96% rename from android/app/src/main/java/com/schoolmesh/messenger/data/MeshRepository.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt index e93556c..a68efce 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/data/MeshRepository.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt @@ -1,7 +1,7 @@ -package com.schoolmesh.messenger.data +package pro.nnnteam.nnnet.data -import com.schoolmesh.messenger.mesh.MeshPacket -import com.schoolmesh.messenger.mesh.PacketType +import pro.nnnteam.nnnet.mesh.MeshPacket +import pro.nnnteam.nnnet.mesh.PacketType import java.util.UUID class MeshRepository( diff --git a/android/app/src/main/java/com/schoolmesh/messenger/data/MessageDao.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/MessageDao.kt similarity index 97% rename from android/app/src/main/java/com/schoolmesh/messenger/data/MessageDao.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/data/MessageDao.kt index 6e9e6dd..e78d133 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/data/MessageDao.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/MessageDao.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.data +package pro.nnnteam.nnnet.data import androidx.room.Dao import androidx.room.Insert diff --git a/android/app/src/main/java/com/schoolmesh/messenger/data/MessageEntity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/MessageEntity.kt similarity index 91% rename from android/app/src/main/java/com/schoolmesh/messenger/data/MessageEntity.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/data/MessageEntity.kt index aa024ab..2dfcb84 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/data/MessageEntity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/MessageEntity.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.data +package pro.nnnteam.nnnet.data import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/android/app/src/main/java/com/schoolmesh/messenger/data/OutboundQueueDao.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/OutboundQueueDao.kt similarity index 96% rename from android/app/src/main/java/com/schoolmesh/messenger/data/OutboundQueueDao.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/data/OutboundQueueDao.kt index eba9e1e..3eb7b6f 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/data/OutboundQueueDao.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/OutboundQueueDao.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.data +package pro.nnnteam.nnnet.data import androidx.room.Dao import androidx.room.Insert diff --git a/android/app/src/main/java/com/schoolmesh/messenger/data/OutboundQueueEntity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/OutboundQueueEntity.kt similarity index 90% rename from android/app/src/main/java/com/schoolmesh/messenger/data/OutboundQueueEntity.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/data/OutboundQueueEntity.kt index 13b0b6f..624bf50 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/data/OutboundQueueEntity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/OutboundQueueEntity.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.data +package pro.nnnteam.nnnet.data import androidx.room.Entity import androidx.room.PrimaryKey diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/BleMeshManager.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt similarity index 91% rename from android/app/src/main/java/com/schoolmesh/messenger/mesh/BleMeshManager.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt index c6c7324..f6e8d40 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/mesh/BleMeshManager.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.mesh +package pro.nnnteam.nnnet.mesh import android.Manifest import android.annotation.SuppressLint @@ -69,22 +69,22 @@ class BleMeshManager( if (address == localNodeId || activeConnections.containsKey(address)) { return } - log("Discovered BLE node: $address") + log("Обнаружен BLE-узел: $address") connectToPeer(device) } override fun onScanFailed(errorCode: Int) { - fail("BLE scan failed: $errorCode") + fail("Ошибка BLE-сканирования: $errorCode") } } private val advertiseCallback = object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { - log("BLE advertising started") + log("Запущен BLE advertising") } override fun onStartFailure(errorCode: Int) { - fail("BLE advertising failed: $errorCode") + fail("Ошибка BLE advertising: $errorCode") } } @@ -187,13 +187,13 @@ class BleMeshManager( fun start() { if (isRunning) return if (!hasRequiredRuntimePermissions()) { - fail("BLE permissions are missing") + fail("Не выданы BLE-разрешения") return } val adapter = bluetoothAdapter if (adapter == null || !adapter.isEnabled) { - fail("Bluetooth adapter is unavailable or disabled") + fail("Bluetooth недоступен или выключен") return } @@ -201,7 +201,7 @@ class BleMeshManager( startScanning() startAdvertising() isRunning = true - onStatusChanged("Mesh активен, идет discovery и GATT transport") + onStatusChanged("NNNet в сети, поиск соседей и транспорт GATT активны") log("BLE mesh manager started with nodeId=$localNodeId") } @@ -219,34 +219,34 @@ class BleMeshManager( inboundCharacteristic = null gattServer = null isRunning = false - onStatusChanged("Mesh остановлен") + onStatusChanged("NNNet оффлайн") log("BLE mesh manager stopped") } private fun handleIncomingPacket(rawPacket: String) { val packet = runCatching { MeshPacketCodec.decode(rawPacket) } .getOrElse { - fail("Packet decode failed: ${it.message}") + fail("Не удалось декодировать пакет: ${it.message}") return } when (val action = onPacketReceived(packet)) { - MeshAction.DropDuplicate -> log("Duplicate packet dropped: ${packet.messageId}") - MeshAction.DropExpired -> log("Expired packet dropped: ${packet.messageId}") + MeshAction.DropDuplicate -> log("Дубликат пакета отброшен: ${packet.messageId}") + MeshAction.DropExpired -> log("Просроченный пакет отброшен: ${packet.messageId}") is MeshAction.ConsumeAck -> { onAckReceived(action.messageId) - log("ACK consumed: ${action.messageId}") + log("ACK обработан: ${action.messageId}") } is MeshAction.ConsumePresence -> { onPeerDiscovered(action.senderId) - onStatusChanged("Presence from ${action.senderId}") - log("Presence consumed from ${action.senderId}") + onStatusChanged("Устройство ${action.senderId} рядом") + log("Сигнал присутствия обработан от ${action.senderId}") } is MeshAction.ProcessAndRelay -> { onMessageReceived(packet) - onStatusChanged("Message from ${packet.senderId}") - log("Relaying packet ${packet.messageId}") + onStatusChanged("Новое сообщение от ${packet.senderId}") + log("Ретрансляция пакета ${packet.messageId}") broadcastPacket(action.packetToRelay) sendAck(packet) } @@ -256,7 +256,7 @@ class BleMeshManager( @SuppressLint("MissingPermission") private fun startGattServer() { val manager = bluetoothManager ?: run { - fail("BluetoothManager unavailable") + fail("BluetoothManager недоступен") return } diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshForegroundService.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt similarity index 87% rename from android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshForegroundService.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt index 08a0326..c01b7e5 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshForegroundService.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.mesh +package pro.nnnteam.nnnet.mesh import android.app.Notification import android.app.NotificationChannel @@ -9,9 +9,9 @@ import android.content.Intent import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat -import com.schoolmesh.messenger.R -import com.schoolmesh.messenger.data.MeshDatabase -import com.schoolmesh.messenger.data.MeshRepository +import pro.nnnteam.nnnet.R +import pro.nnnteam.nnnet.data.MeshDatabase +import pro.nnnteam.nnnet.data.MeshRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -33,7 +33,7 @@ class MeshForegroundService : Service() { context = applicationContext, onPeerDiscovered = { address -> sendEvent(MeshServiceContract.EVENT_PEER, address) - sendEvent(MeshServiceContract.EVENT_LOG, "Peer discovered: $address") + sendEvent(MeshServiceContract.EVENT_LOG, "Устройство обнаружено: $address") queueProcessor.poke() }, onStatusChanged = { status -> @@ -43,21 +43,21 @@ class MeshForegroundService : Service() { onAckReceived = { messageId -> serviceScope.launch { repository.markAckDelivered(messageId) - sendEvent(MeshServiceContract.EVENT_LOG, "ACK delivered for $messageId") + sendEvent(MeshServiceContract.EVENT_LOG, "ACK получен для $messageId") sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId) } }, onMessageReceived = { packet -> serviceScope.launch { repository.recordIncomingMessage(packet) - sendEvent(MeshServiceContract.EVENT_LOG, "Message stored from ${packet.senderId}") + sendEvent(MeshServiceContract.EVENT_LOG, "Сообщение сохранено от ${packet.senderId}") sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, packet.messageId) } }, onError = { message -> sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message") - sendEvent(MeshServiceContract.EVENT_LOG, "Error: $message") - updateNotification("Ошибка mesh") + sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message") + updateNotification("Ошибка сети") }, onLog = { message -> sendEvent(MeshServiceContract.EVENT_LOG, message) @@ -90,7 +90,7 @@ class MeshForegroundService : Service() { override fun onBind(intent: Intent?): IBinder? = null private fun startMesh() { - startForeground(NOTIFICATION_ID, buildNotification("Mesh запускается")) + startForeground(NOTIFICATION_ID, buildNotification("NNNet запускает сеть")) bleMeshManager.start() queueProcessor.start() queueProcessor.poke() @@ -107,7 +107,7 @@ class MeshForegroundService : Service() { val targetId = intent.getStringExtra(MeshServiceContract.EXTRA_TARGET_ID)?.trim().orEmpty() val messageBody = intent.getStringExtra(MeshServiceContract.EXTRA_MESSAGE_BODY)?.trim().orEmpty() if (targetId.isEmpty() || messageBody.isEmpty()) { - sendEvent(MeshServiceContract.EVENT_LOG, "Cannot enqueue empty target/body") + sendEvent(MeshServiceContract.EVENT_LOG, "Нельзя поставить в очередь пустое сообщение") return } @@ -117,7 +117,7 @@ class MeshForegroundService : Service() { targetId = targetId, body = messageBody ) - sendEvent(MeshServiceContract.EVENT_LOG, "Message queued: $messageId") + sendEvent(MeshServiceContract.EVENT_LOG, "Сообщение поставлено в очередь: $messageId") sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId) queueProcessor.poke() } diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacket.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshPacket.kt similarity index 92% rename from android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacket.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshPacket.kt index fd9e516..02c14d1 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacket.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshPacket.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.mesh +package pro.nnnteam.nnnet.mesh import java.util.UUID diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacketCodec.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshPacketCodec.kt similarity index 96% rename from android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacketCodec.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshPacketCodec.kt index 4bb9b43..cd47c2c 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacketCodec.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshPacketCodec.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.mesh +package pro.nnnteam.nnnet.mesh import org.json.JSONObject diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshQueueProcessor.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshQueueProcessor.kt similarity index 96% rename from android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshQueueProcessor.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshQueueProcessor.kt index af67edc..7bb576f 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshQueueProcessor.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshQueueProcessor.kt @@ -1,6 +1,6 @@ -package com.schoolmesh.messenger.mesh +package pro.nnnteam.nnnet.mesh -import com.schoolmesh.messenger.data.MeshRepository +import pro.nnnteam.nnnet.data.MeshRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshServiceContract.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshServiceContract.kt similarity index 54% rename from android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshServiceContract.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshServiceContract.kt index 1f3b573..563f738 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshServiceContract.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshServiceContract.kt @@ -1,10 +1,10 @@ -package com.schoolmesh.messenger.mesh +package pro.nnnteam.nnnet.mesh object MeshServiceContract { - const val ACTION_START = "com.schoolmesh.messenger.mesh.START" - const val ACTION_STOP = "com.schoolmesh.messenger.mesh.STOP" - const val ACTION_SEND_MESSAGE = "com.schoolmesh.messenger.mesh.SEND_MESSAGE" - const val ACTION_EVENT = "com.schoolmesh.messenger.mesh.EVENT" + const val ACTION_START = "pro.nnnteam.nnnet.mesh.START" + const val ACTION_STOP = "pro.nnnteam.nnnet.mesh.STOP" + const val ACTION_SEND_MESSAGE = "pro.nnnteam.nnnet.mesh.SEND_MESSAGE" + const val ACTION_EVENT = "pro.nnnteam.nnnet.mesh.EVENT" const val EXTRA_EVENT_TYPE = "event_type" const val EXTRA_EVENT_VALUE = "event_value" diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/PacketType.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/PacketType.kt similarity index 62% rename from android/app/src/main/java/com/schoolmesh/messenger/mesh/PacketType.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/mesh/PacketType.kt index 1f80edb..2fe21e2 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/mesh/PacketType.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/PacketType.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.mesh +package pro.nnnteam.nnnet.mesh enum class PacketType { MESSAGE, diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/SeenPacketCache.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/SeenPacketCache.kt similarity index 92% rename from android/app/src/main/java/com/schoolmesh/messenger/mesh/SeenPacketCache.kt rename to android/app/src/main/java/pro/nnnteam/nnnet/mesh/SeenPacketCache.kt index a349c48..e97bd9f 100644 --- a/android/app/src/main/java/com/schoolmesh/messenger/mesh/SeenPacketCache.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/SeenPacketCache.kt @@ -1,4 +1,4 @@ -package com.schoolmesh.messenger.mesh +package pro.nnnteam.nnnet.mesh class SeenPacketCache( private val maxSize: Int = 512 diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListAdapter.kt b/android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListAdapter.kt new file mode 100644 index 0000000..279c1f7 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListAdapter.kt @@ -0,0 +1,50 @@ +package pro.nnnteam.nnnet.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.TextView +import pro.nnnteam.nnnet.R +import pro.nnnteam.nnnet.data.ChatSummary +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class ChatListAdapter( + context: Context, + private val items: MutableList +) : BaseAdapter() { + private val inflater = LayoutInflater.from(context) + private val timeFormatter = SimpleDateFormat("HH:mm", Locale("ru")) + + override fun getCount(): Int = items.size + + override fun getItem(position: Int): ChatSummary = items[position] + + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: inflater.inflate(R.layout.item_chat_summary, parent, false) + val item = getItem(position) + + view.findViewById(R.id.avatarText).text = avatarLetter(item.peerId) + view.findViewById(R.id.chatNameText).text = item.peerId + view.findViewById(R.id.chatPreviewText).text = item.lastBody + view.findViewById(R.id.chatTimeText).text = timeFormatter.format(Date(item.lastTimestamp)) + view.findViewById(R.id.chatStatusText).text = statusLabel(item.lastStatus) + + return view + } + + private fun avatarLetter(peerId: String): String = peerId.firstOrNull()?.uppercase() ?: "N" + + private fun statusLabel(status: String): String = when (status) { + "queued" -> "В очереди" + "sent" -> "Отправлено" + "delivered" -> "Доставлено" + "failed" -> "Ошибка" + else -> status + } +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/ui/MessageListAdapter.kt b/android/app/src/main/java/pro/nnnteam/nnnet/ui/MessageListAdapter.kt new file mode 100644 index 0000000..e762035 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/ui/MessageListAdapter.kt @@ -0,0 +1,71 @@ +package pro.nnnteam.nnnet.ui + +import android.content.Context +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import pro.nnnteam.nnnet.R +import pro.nnnteam.nnnet.data.MessageEntity +import pro.nnnteam.nnnet.data.MeshRepository +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class MessageListAdapter( + context: Context, + private val items: MutableList +) : BaseAdapter() { + private val inflater = LayoutInflater.from(context) + private val timeFormatter = SimpleDateFormat("HH:mm", Locale("ru")) + + override fun getCount(): Int = items.size + + override fun getItem(position: Int): MessageEntity = items[position] + + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: inflater.inflate(R.layout.item_message, parent, false) + val item = getItem(position) + val bubble = view.findViewById(R.id.messageBubble) + val container = view.findViewById(R.id.messageContainer) + val bodyText = view.findViewById(R.id.messageBodyText) + val metaText = view.findViewById(R.id.messageMetaText) + + val isOutgoing = item.direction == MeshRepository.DIRECTION_OUTGOING + val params = bubble.layoutParams as FrameLayout.LayoutParams + params.gravity = if (isOutgoing) Gravity.END else Gravity.START + bubble.layoutParams = params + bubble.background = ContextCompat.getDrawable( + view.context, + if (isOutgoing) R.drawable.bg_message_outgoing else R.drawable.bg_message_incoming + ) + container.foreground = null + bodyText.text = item.body + metaText.text = buildString { + append(timeFormatter.format(Date(item.createdAt))) + append(" · ") + append(statusLabel(item.status, isOutgoing)) + } + metaText.gravity = if (isOutgoing) Gravity.END else Gravity.START + + return view + } + + private fun statusLabel(status: String, isOutgoing: Boolean): String { + if (!isOutgoing) return "Получено" + return when (status) { + "queued" -> "В очереди" + "sent" -> "Отправлено" + "delivered" -> "Доставлено" + "failed" -> "Ошибка отправки" + else -> status + } + } +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/update/UpdateManager.kt b/android/app/src/main/java/pro/nnnteam/nnnet/update/UpdateManager.kt new file mode 100644 index 0000000..c354cfb --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/update/UpdateManager.kt @@ -0,0 +1,53 @@ +package pro.nnnteam.nnnet.update + +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +object UpdateManager { + const val PREFS_NAME = "nnnet_settings" + const val KEY_AUTO_UPDATE = "auto_update" + private const val BASE_URL = "https://net.nnn-team.pro" + private const val METADATA_URL = "$BASE_URL/assets/meta/version.json" + + fun fetchUpdateInfo(): UpdateInfo? { + return runCatching { + val connection = URL(METADATA_URL).openConnection() as HttpURLConnection + connection.connectTimeout = 5_000 + connection.readTimeout = 5_000 + connection.inputStream.bufferedReader().use { reader -> + val json = JSONObject(reader.readText()) + UpdateInfo( + versionCode = json.getInt("versionCode"), + versionName = json.getString("versionName"), + apkPath = json.getString("apkPath"), + releaseNotesTitle = json.optString("releaseNotesTitle", "Что нового"), + releaseNotesPath = json.optString("releaseNotesPath", "") + ) + } + }.getOrNull() + } + + fun fetchReleaseNotes(path: String): String? { + if (path.isBlank()) return null + val url = buildDownloadUrl(path) + return runCatching { + val connection = URL(url).openConnection() as HttpURLConnection + connection.connectTimeout = 5_000 + connection.readTimeout = 5_000 + connection.inputStream.bufferedReader().use { it.readText() } + }.getOrNull() + } + + fun buildDownloadUrl(path: String): String { + return if (path.startsWith("http")) path else "$BASE_URL/${path.trimStart('/')}" + } +} + +data class UpdateInfo( + val versionCode: Int, + val versionName: String, + val apkPath: String, + val releaseNotesTitle: String, + val releaseNotesPath: String +) diff --git a/android/app/src/main/res/drawable/bg_chat_avatar.xml b/android/app/src/main/res/drawable/bg_chat_avatar.xml new file mode 100644 index 0000000..8152ed3 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_chat_avatar.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/drawable/bg_message_incoming.xml b/android/app/src/main/res/drawable/bg_message_incoming.xml new file mode 100644 index 0000000..c923bb4 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_message_incoming.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_message_input.xml b/android/app/src/main/res/drawable/bg_message_input.xml new file mode 100644 index 0000000..ee1dbd0 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_message_input.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_message_outgoing.xml b/android/app/src/main/res/drawable/bg_message_outgoing.xml new file mode 100644 index 0000000..c10324f --- /dev/null +++ b/android/app/src/main/res/drawable/bg_message_outgoing.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_send_button.xml b/android/app/src/main/res/drawable/bg_send_button.xml new file mode 100644 index 0000000..2f43ec1 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_send_button.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/drawable/bg_settings_card.xml b/android/app/src/main/res/drawable/bg_settings_card.xml new file mode 100644 index 0000000..c923bb4 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_settings_card.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_status_offline.xml b/android/app/src/main/res/drawable/bg_status_offline.xml new file mode 100644 index 0000000..cb31ca2 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_status_offline.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_status_online.xml b/android/app/src/main/res/drawable/bg_status_online.xml new file mode 100644 index 0000000..263e1ed --- /dev/null +++ b/android/app/src/main/res/drawable/bg_status_online.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/layout/activity_chat.xml b/android/app/src/main/res/layout/activity_chat.xml new file mode 100644 index 0000000..97dde25 --- /dev/null +++ b/android/app/src/main/res/layout/activity_chat.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index c460e1a..ecd6425 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -1,242 +1,124 @@ - + android:background="@color/screen_background"> - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + android:background="@color/top_bar_background" + android:paddingStart="16dp" + android:paddingBottom="14dp" + android:text="@string/chats_title" + android:textColor="@color/top_bar_secondary_text" + android:textSize="14sp" /> - - - - - - - - - + android:background="@color/screen_background" + android:divider="@color/chat_divider" + android:dividerHeight="1dp" + android:listSelector="@android:color/transparent" + android:paddingTop="4dp" + android:paddingBottom="92dp" + android:scrollbars="none" /> - - - - - - - - - - - + android:layout_gravity="center" + android:gravity="center" + android:padding="24dp" + android:text="@string/no_chats" + android:textColor="@color/secondary_text" + android:visibility="gone" /> - - - - - - - - - - - - - - - - - - - - + + diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..22954ee --- /dev/null +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_chat_summary.xml b/android/app/src/main/res/layout/item_chat_summary.xml new file mode 100644 index 0000000..b48272e --- /dev/null +++ b/android/app/src/main/res/layout/item_chat_summary.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_message.xml b/android/app/src/main/res/layout/item_message.xml new file mode 100644 index 0000000..02c6c05 --- /dev/null +++ b/android/app/src/main/res/layout/item_message.xml @@ -0,0 +1,38 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/menu/main_menu.xml b/android/app/src/main/res/menu/main_menu.xml new file mode 100644 index 0000000..4ac1bb6 --- /dev/null +++ b/android/app/src/main/res/menu/main_menu.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 6d58576..3cbc32c 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,5 +1,13 @@ - #1E6E54 - #A4F3D5 - #1150B4 + #F4F6F8 + #D9EAF4 + #527DA3 + #D8E6F1 + #1E2B37 + #72879A + #4C9EEB + #4C9EEB + #DDE4EA + #EDF2F6 + #6C7E8F diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3029eea..0c372ef 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,5 +1,37 @@ NNNet NNNet - Mesh status + Статус mesh-сети + Меню + Настройки + Чаты + В сети + Не в сети + Устройств: %1$d + Чатов пока нет. Нажмите +, чтобы открыть новый диалог. + Новый чат + Новый диалог + Идентификатор устройства + Открыть чат + Отмена + Без разрешений BLE сеть не запустится + Для работы нужен включённый Bluetooth + Bluetooth на устройстве недоступен + Введите ID устройства + Не удалось проверить обновления + У вас уже установлена последняя версия + Доступна версия %1$s. + Скачать обновление + Позже + Назад + Сообщений пока нет. Напишите первым. + Сообщение + Отправить + Введите сообщение + Ожидание подключения mesh-сети + Устройство рядом + Сообщение ставится в очередь на отправку + Автоматически проверять обновления + Проверить обновления + Текущая версия: %1$s (%2$d) diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index f8bec9c..057e967 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,7 +1,8 @@ - - diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4df7c24..985a3c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,11 +3,23 @@ ## Слои - BLE Transport: сканирование, реклама, соединения, обмен пакетами. - Mesh Layer: маршрутизация, TTL, дедупликация, ACK. -- Messaging Layer: список чатов, диалог, статусы доставки, история. +- Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история. - Storage Layer: Room для локального хранения. - Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса. - Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента. +## Пользовательский сценарий +- Главный экран показывает список чатов в стиле Telegram. +- Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`. +- Слева в шапке показывается общее количество известных устройств в mesh. +- Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран. +- Отправка сообщений доступна только из экрана конкретного диалога. + +## Топология сети +- Выделенный сервер или хост для работы mesh не нужен. +- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором. +- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону. + ## Сетевой пакет (черновик) ```json { diff --git a/website/assets/js/app.js b/website/assets/js/app.js index 819b8b6..2115fe8 100644 --- a/website/assets/js/app.js +++ b/website/assets/js/app.js @@ -60,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => { releaseNotesText.innerHTML = notes.trim().replace(/\n/g, ''); }) .catch(() => { - releaseNotesText.textContent = 'Не удалось загрузить changelog.'; + releaseNotesText.textContent = 'Не удалось загрузить описание обновления.'; }); } }) diff --git a/website/index.html b/website/index.html index 174ee5d..bd3f7d7 100644 --- a/website/index.html +++ b/website/index.html @@ -40,9 +40,9 @@ Что внутри - BLE DiscoveryОбнаружение ближайших узлов и обмен пакетами. - Mesh RelayПередача сообщений hop-by-hop с TTL, ACK и retry queue. - Local StorageRoom хранит историю сообщений и очередь исходящей доставки. + BLE-поискОбнаружение ближайших узлов и обмен пакетами без интернета. + Mesh-ретрансляцияПередача сообщений hop-by-hop с TTL, ACK и повторными попытками. + Локальное хранениеRoom хранит историю сообщений и очередь исходящей доставки. @@ -65,6 +65,6 @@ - +
Обнаружение ближайших узлов и обмен пакетами.
Передача сообщений hop-by-hop с TTL, ACK и retry queue.
Room хранит историю сообщений и очередь исходящей доставки.
Обнаружение ближайших узлов и обмен пакетами без интернета.
Передача сообщений hop-by-hop с TTL, ACK и повторными попытками.