Refine NNNet UI and rename Android package
Some checks failed
Android CI / build (push) Has been cancelled

This commit is contained in:
dom4k
2026-03-16 20:29:49 +00:00
parent 3f304e901c
commit 1cfdb42e04
45 changed files with 1430 additions and 777 deletions

View File

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

View File

@@ -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">
<activity
android:name=".SettingsActivity"
android:exported="false" />
<activity
android:name=".ChatActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".MainActivity"
android:exported="true">

View File

@@ -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<String>()
private val logs = ArrayDeque<String>()
private val chatSummaries = mutableListOf<ChatSummary>()
private val chatItems = mutableListOf<String>()
private val messageItems = mutableListOf<String>()
private lateinit var chatAdapter: ArrayAdapter<String>
private lateinit var messageAdapter: ArrayAdapter<String>
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<Button>(R.id.btnTabChats).setOnClickListener { showChats() }
findViewById<Button>(R.id.btnTabSettings).setOnClickListener { showSettings() }
findViewById<Button>(R.id.btnStartMesh).setOnClickListener {
pendingStartRequested = true
ensurePermissionsAndMaybeStart()
}
findViewById<Button>(R.id.btnStopMesh).setOnClickListener {
MeshForegroundService.stop(this)
updateStatus("Mesh stopped")
appendLog("Mesh service stop requested")
}
findViewById<Button>(R.id.btnSendMessage).setOnClickListener {
enqueueMessageFromUi()
}
findViewById<Button>(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<String> {
val permissions = mutableListOf<String>()
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
)
}

View File

@@ -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<MessageEntity>()
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<ImageButton>(R.id.backButton).setOnClickListener { finish() }
findViewById<View>(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<String> {
val permissions = mutableListOf<String>()
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"
}
}

View File

@@ -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<String>()
private val chatSummaries = mutableListOf<ChatSummary>()
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<ImageButton>(R.id.menuButton).setOnClickListener { showMenu(it) }
findViewById<FloatingActionButton>(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<String> {
val permissions = mutableListOf<String>()
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
}
}

View File

@@ -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<ImageButton>(R.id.backButton).setOnClickListener { finish() }
val autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch)
val versionText = findViewById<TextView>(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<android.view.View>(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
}
}
}

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data
package pro.nnnteam.nnnet.data
data class ChatSummary(
val peerId: String,

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data
package pro.nnnteam.nnnet.data
import android.content.Context
import androidx.room.Database

View File

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

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data
package pro.nnnteam.nnnet.data
import androidx.room.Dao
import androidx.room.Insert

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data
package pro.nnnteam.nnnet.data
import androidx.room.Entity
import androidx.room.PrimaryKey

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data
package pro.nnnteam.nnnet.data
import androidx.room.Dao
import androidx.room.Insert

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data
package pro.nnnteam.nnnet.data
import androidx.room.Entity
import androidx.room.PrimaryKey

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.mesh
package pro.nnnteam.nnnet.mesh
import java.util.UUID

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.mesh
package pro.nnnteam.nnnet.mesh
import org.json.JSONObject

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.mesh
package pro.nnnteam.nnnet.mesh
enum class PacketType {
MESSAGE,

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.mesh
package pro.nnnteam.nnnet.mesh
class SeenPacketCache(
private val maxSize: Int = 512

View File

@@ -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<ChatSummary>
) : 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<TextView>(R.id.avatarText).text = avatarLetter(item.peerId)
view.findViewById<TextView>(R.id.chatNameText).text = item.peerId
view.findViewById<TextView>(R.id.chatPreviewText).text = item.lastBody
view.findViewById<TextView>(R.id.chatTimeText).text = timeFormatter.format(Date(item.lastTimestamp))
view.findViewById<TextView>(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
}
}

View File

@@ -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<MessageEntity>
) : 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<LinearLayout>(R.id.messageBubble)
val container = view.findViewById<FrameLayout>(R.id.messageContainer)
val bodyText = view.findViewById<TextView>(R.id.messageBodyText)
val metaText = view.findViewById<TextView>(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
}
}
}

View File

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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#4E8DF5" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="24dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#E4FFC7" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#4C9EEB" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#5F7488" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#33A56E" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/chat_background"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/top_bar_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="10dp"
android:paddingEnd="12dp"
android:paddingBottom="10dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/back"
android:src="@android:drawable/ic_media_previous"
android:tint="@android:color/white" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/chatTitleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/chatSubtitleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/top_bar_secondary_text"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ListView
android:id="@+id/messageListView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:divider="@android:color/transparent"
android:dividerHeight="8dp"
android:listSelector="@android:color/transparent"
android:paddingStart="10dp"
android:paddingTop="10dp"
android:paddingEnd="10dp"
android:paddingBottom="10dp"
android:scrollbars="none"
android:transcriptMode="alwaysScroll" />
<TextView
android:id="@+id/emptyStateText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:padding="24dp"
android:text="@string/no_messages"
android:textColor="@color/secondary_text"
android:visibility="gone" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/message_input_panel"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="10dp"
android:paddingTop="8dp"
android:paddingEnd="10dp"
android:paddingBottom="8dp">
<EditText
android:id="@+id/messageInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_message_input"
android:hint="@string/message_hint"
android:maxLines="5"
android:minHeight="48dp"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="10dp"
android:background="@drawable/bg_send_button"
android:contentDescription="@string/send"
android:padding="12dp"
android:src="@android:drawable/ic_menu_send"
android:tint="@android:color/white" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,242 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#18222D"
android:orientation="vertical"
tools:context=".MainActivity">
android:background="@color/screen_background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#233040"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp">
<TextView
android:id="@+id/titleText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="NNNet"
android:textColor="#F3F7FB"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#2F6EA5"
android:paddingHorizontal="10dp"
android:paddingVertical="6dp"
android:text="Offline"
android:textColor="#FFFFFF" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#233040"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:paddingBottom="12dp">
<Button
android:id="@+id/btnTabChats"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:text="Chats" />
<Button
android:id="@+id/btnTabSettings"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Settings" />
</LinearLayout>
<LinearLayout
android:id="@+id/chatsScreen"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/top_bar_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="18dp"
android:paddingEnd="8dp"
android:paddingBottom="14dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="NNNet"
android:textColor="@android:color/white"
android:textSize="23sp"
android:textStyle="bold" />
<TextView
android:id="@+id/deviceCountText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:textColor="@color/top_bar_secondary_text"
android:textSize="13sp" />
</LinearLayout>
<FrameLayout
android:id="@+id/statusBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_status_offline"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackgroundBorderless"
android:paddingHorizontal="14dp"
android:paddingVertical="8dp">
<TextView
android:id="@+id/statusBadgeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:textSize="13sp"
android:textStyle="bold" />
</FrameLayout>
<ImageButton
android:id="@+id/menuButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/menu"
android:padding="8dp"
android:src="@android:drawable/ic_menu_more"
android:tint="@android:color/white" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#121A23"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp"
android:text="Chats"
android:textColor="#8FA1B3"
android:textStyle="bold" />
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" />
<ListView
android:id="@+id/chatListView"
android:layout_width="match_parent"
android:layout_height="180dp"
android:background="#10161E"
android:divider="#1A2531"
android:dividerHeight="1dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#1F2C39"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/activeChatTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Select a chat"
android:textColor="#F3F7FB"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/peersText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Nearby peers will appear here"
android:textColor="#8FA1B3" />
</LinearLayout>
<ListView
android:id="@+id/messageListView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="#0E1621"
android:divider="@android:color/transparent"
android:dividerHeight="8dp"
android:padding="12dp"
android:transcriptMode="alwaysScroll" />
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" />
<LinearLayout
<TextView
android:id="@+id/emptyStateText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#18222D"
android:orientation="vertical"
android:padding="12dp">
<EditText
android:id="@+id/targetInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#233040"
android:hint="Peer ID"
android:padding="12dp"
android:textColor="#F3F7FB"
android:textColorHint="#8FA1B3" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<EditText
android:id="@+id/messageInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#233040"
android:hint="Write a message"
android:minLines="2"
android:padding="12dp"
android:textColor="#F3F7FB"
android:textColorHint="#8FA1B3" />
<Button
android:id="@+id/btnSendMessage"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:text="Send" />
</LinearLayout>
</LinearLayout>
android:layout_gravity="center"
android:gravity="center"
android:padding="24dp"
android:text="@string/no_chats"
android:textColor="@color/secondary_text"
android:visibility="gone" />
</LinearLayout>
<ScrollView
android:id="@+id/settingsScreen"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Settings"
android:textColor="#F3F7FB"
android:textSize="20sp"
android:textStyle="bold" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoUpdateSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Enable auto update checks"
android:textColor="#F3F7FB"
app:useMaterialThemeColors="false" />
<Button
android:id="@+id/btnCheckUpdates"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Check updates now" />
<Button
android:id="@+id/btnStartMesh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Start mesh" />
<Button
android:id="@+id/btnStopMesh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Stop mesh" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Event log"
android:textColor="#8FA1B3"
android:textStyle="bold" />
<TextView
android:id="@+id/logsText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#101820"
android:padding="12dp"
android:text="Log is empty"
android:textColor="#EAF7F2"
android:textIsSelectable="true" />
</LinearLayout>
</ScrollView>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/newChatButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="20dp"
android:contentDescription="@string/new_chat"
android:src="@android:drawable/ic_input_add"
app:backgroundTint="@color/fab_background"
app:tint="@android:color/white" />
</FrameLayout>

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/screen_background"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/top_bar_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/back"
android:src="@android:drawable/ic_media_previous"
android:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_title"
android:textColor="@android:color/white"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/versionText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_settings_card"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textSize="15sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoUpdateSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_settings_card"
android:padding="16dp"
android:text="@string/auto_update"
android:textColor="@color/primary_text"
app:useMaterialThemeColors="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/checkUpdatesButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/check_updates"
app:cornerRadius="18dp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="76dp"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp">
<TextView
android:id="@+id/avatarText"
android:layout_width="52dp"
android:layout_height="52dp"
android:background="@drawable/bg_chat_avatar"
android:gravity="center"
android:textColor="@android:color/white"
android:textSize="20sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/chatNameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/primary_text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/chatPreviewText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/secondary_text"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="end"
android:orientation="vertical">
<TextView
android:id="@+id/chatTimeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/secondary_text"
android:textSize="12sp" />
<TextView
android:id="@+id/chatStatusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/accent_blue"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/messageContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="4dp"
android:paddingEnd="4dp">
<LinearLayout
android:id="@+id/messageBubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginTop="2dp"
android:layout_marginBottom="2dp"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/messageBodyText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="260dp"
android:textColor="@color/primary_text"
android:textSize="16sp" />
<TextView
android:id="@+id/messageMetaText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/message_meta"
android:textSize="11sp" />
</LinearLayout>
</FrameLayout>

View File

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

View File

@@ -1,5 +1,13 @@
<resources>
<color name="teal_primary">#1E6E54</color>
<color name="teal_container">#A4F3D5</color>
<color name="blue_secondary">#1150B4</color>
<color name="screen_background">#F4F6F8</color>
<color name="chat_background">#D9EAF4</color>
<color name="top_bar_background">#527DA3</color>
<color name="top_bar_secondary_text">#D8E6F1</color>
<color name="primary_text">#1E2B37</color>
<color name="secondary_text">#72879A</color>
<color name="accent_blue">#4C9EEB</color>
<color name="fab_background">#4C9EEB</color>
<color name="chat_divider">#DDE4EA</color>
<color name="message_input_panel">#EDF2F6</color>
<color name="message_meta">#6C7E8F</color>
</resources>

View File

@@ -1,5 +1,37 @@
<resources>
<string name="app_name">NNNet</string>
<string name="notification_title">NNNet</string>
<string name="notification_channel_name">Mesh status</string>
<string name="notification_channel_name">Статус mesh-сети</string>
<string name="menu">Меню</string>
<string name="settings_title">Настройки</string>
<string name="chats_title">Чаты</string>
<string name="status_online">В сети</string>
<string name="status_offline">Не в сети</string>
<string name="total_devices">Устройств: %1$d</string>
<string name="no_chats">Чатов пока нет. Нажмите +, чтобы открыть новый диалог.</string>
<string name="new_chat">Новый чат</string>
<string name="new_chat_title">Новый диалог</string>
<string name="hint_peer_id">Идентификатор устройства</string>
<string name="open_chat">Открыть чат</string>
<string name="cancel">Отмена</string>
<string name="permissions_denied">Без разрешений BLE сеть не запустится</string>
<string name="bluetooth_required">Для работы нужен включённый Bluetooth</string>
<string name="bluetooth_unavailable">Bluetooth на устройстве недоступен</string>
<string name="peer_id_required">Введите ID устройства</string>
<string name="update_check_failed">Не удалось проверить обновления</string>
<string name="latest_version_installed">У вас уже установлена последняя версия</string>
<string name="update_available_message">Доступна версия %1$s.</string>
<string name="download_update">Скачать обновление</string>
<string name="later">Позже</string>
<string name="back">Назад</string>
<string name="no_messages">Сообщений пока нет. Напишите первым.</string>
<string name="message_hint">Сообщение</string>
<string name="send">Отправить</string>
<string name="message_required">Введите сообщение</string>
<string name="chat_waiting_status">Ожидание подключения mesh-сети</string>
<string name="peer_nearby">Устройство рядом</string>
<string name="message_sending">Сообщение ставится в очередь на отправку</string>
<string name="auto_update">Автоматически проверять обновления</string>
<string name="check_updates">Проверить обновления</string>
<string name="current_version">Текущая версия: %1$s (%2$d)</string>
</resources>

View File

@@ -1,7 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.SchoolMeshMessenger" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">#1E6E54</item>
<item name="colorPrimaryContainer">#A4F3D5</item>
<item name="colorSecondary">#1150B4</item>
<resources>
<style name="Theme.NNNet" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">#4C9EEB</item>
<item name="colorPrimaryContainer">#A8D6FA</item>
<item name="colorSecondary">#527DA3</item>
<item name="android:statusBarColor">#527DA3</item>
</style>
</resources>