Files
NNNet/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt

395 lines
15 KiB
Kotlin

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.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.ui.ChatListItem
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 chatItems = mutableListOf<ChatListItem>()
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,
MeshServiceContract.EVENT_PROFILES_CHANGED -> refreshChats()
}
}
}
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_main)
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
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, chatItems)
chatListView.adapter = chatAdapter
chatListView.setOnItemClickListener { _, _, position, _ ->
openChat(chatItems[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_chat_target)
setSingleLine()
setPadding(48, 32, 48, 32)
}
AlertDialog.Builder(this)
.setTitle(R.string.new_chat_title)
.setView(input)
.setPositiveButton(R.string.open_chat, null)
.setNegativeButton(R.string.cancel, null)
.create()
.also { dialog ->
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val value = input.text.toString().trim()
if (value.isEmpty()) {
Toast.makeText(this, R.string.peer_id_required, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
lifecycleScope.launch {
val resolvedPeerId = resolvePeerId(value)
if (resolvedPeerId != null) {
dialog.dismiss()
openChat(resolvedPeerId)
} else {
Toast.makeText(
this@MainActivity,
R.string.profile_not_found_locally,
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
.show()
}
private suspend fun resolvePeerId(value: String): String? {
val normalized = value.trim().removePrefix("@").lowercase(Locale.getDefault())
return when {
':' in value -> value.trim()
else -> repository.profileByUsername(normalized)?.peerId?.takeIf { it.isNotBlank() }
}
}
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()
val mappedItems = chats.map { chat ->
val profile = repository.profileByPeerId(chat.peerId)
val title = profile?.displayName() ?: chat.peerId
val subtitlePrefix = profile?.let { "@${it.username} · " }.orEmpty()
ChatListItem(
peerId = chat.peerId,
title = title,
subtitle = subtitlePrefix + chat.lastBody,
lastStatus = chat.lastStatus,
lastTimestamp = chat.lastTimestamp
)
}
chatItems.clear()
chatItems.addAll(mappedItems)
chatAdapter.notifyDataSetChanged()
emptyStateText.visibility = if (mappedItems.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("останов") || 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
}
}