395 lines
15 KiB
Kotlin
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
|
|
}
|
|
}
|