Finish NNNet app shell, updates, and docs
Some checks failed
Android CI / build (push) Has been cancelled

This commit is contained in:
dom4k
2026-03-16 19:58:13 +00:00
parent 53fc4c1ff4
commit 3e22bb699e
25 changed files with 1619 additions and 127 deletions

View File

@@ -1,28 +1,73 @@
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?) {
@@ -34,6 +79,10 @@ class MainActivity : AppCompatActivity() {
MeshServiceContract.EVENT_STATUS -> updateStatus(value)
MeshServiceContract.EVENT_PEER -> addPeer(value)
MeshServiceContract.EVENT_LOG -> appendLog(value)
MeshServiceContract.EVENT_MESSAGES_CHANGED -> {
refreshChats()
refreshMessages()
}
}
}
}
@@ -43,11 +92,23 @@ class MainActivity : AppCompatActivity() {
) { result ->
val allGranted = result.values.all { it }
if (allGranted) {
startMesh()
ensureBluetoothEnabledAndContinue()
} else {
updateStatus("Нет BLE-разрешений")
appendLog("Permissions denied by user")
Toast.makeText(this, "Разрешения отклонены", Toast.LENGTH_SHORT).show()
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()
}
}
@@ -55,26 +116,72 @@ class MainActivity : AppCompatActivity() {
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 {
ensurePermissionsAndStart()
pendingStartRequested = true
ensurePermissionsAndMaybeStart()
}
findViewById<Button>(R.id.btnStopMesh).setOnClickListener {
MeshForegroundService.stop(this)
updateStatus("Mesh остановлен")
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() {
@@ -82,6 +189,16 @@ class MainActivity : AppCompatActivity() {
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) {
@@ -92,17 +209,63 @@ class MainActivity : AppCompatActivity() {
}
}
private fun ensurePermissionsAndStart() {
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()) {
startMesh()
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) {
@@ -120,9 +283,151 @@ class MainActivity : AppCompatActivity() {
private fun startMesh() {
MeshForegroundService.start(this)
updateStatus("Запуск foreground service")
updateStatus("Foreground service starting")
appendLog("Mesh service start requested")
Toast.makeText(this, "Mesh запускается", Toast.LENGTH_SHORT).show()
}
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) {
@@ -145,21 +450,38 @@ class MainActivity : AppCompatActivity() {
private fun renderPeers() {
peersText.text = if (peers.isEmpty()) {
"Узлы не найдены"
"Nearby peers will appear here"
} else {
peers.joinToString(separator = "\n")
"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 MAX_LOG_ENTRIES = 20
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
)
}