Refine NNNet UI and rename Android package
Some checks failed
Android CI / build (push) Has been cancelled
Some checks failed
Android CI / build (push) Has been cancelled
This commit is contained in:
356
android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt
Normal file
356
android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user