Files
NNNet/android/app/src/main/java/com/schoolmesh/messenger/MainActivity.kt
dom4k 3e22bb699e
Some checks failed
Android CI / build (push) Has been cancelled
Finish NNNet app shell, updates, and docs
2026-03-16 19:58:13 +00:00

488 lines
18 KiB
Kotlin

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