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

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