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() private val chatItems = mutableListOf() 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(R.id.menuButton).setOnClickListener { showMenu(it) } findViewById(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 { val permissions = mutableListOf() 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 } }