Added the option of auto-configuration of peers, sorting by RTT

This commit is contained in:
JB-SelfCompany
2025-12-16 21:59:29 +03:00
parent fa2acc87e5
commit 46629ba888
32 changed files with 2029 additions and 158 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId "com.jbselfcompany.tyr"
minSdk 23
targetSdk 33
versionCode 19
versionName "1.5.5"
versionCode 20
versionName "1.6.0"
resourceConfigurations += ['en', 'ru']
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'

View File

Binary file not shown.

View File

@@ -26,6 +26,9 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Exact alarm permission for periodic maintenance (Android 12+) -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application
android:name=".TyrApplication"
android:allowBackup="true"

View File

@@ -30,6 +30,11 @@ class ConfigRepository(private val context: Context) {
private const val KEY_LANGUAGE = "language"
private const val KEY_THEME = "theme"
private const val KEY_LOG_COLLECTION_ENABLED = "log_collection_enabled"
private const val KEY_CACHED_DISCOVERED_PEERS = "cached_discovered_peers"
private const val KEY_CACHE_TIMESTAMP = "cache_timestamp"
// Cache TTL for discovered peers (24 hours)
private const val CACHE_TTL_HOURS = 24
// Default Yggdrasil peers
val DEFAULT_PEERS = listOf(
@@ -196,29 +201,25 @@ class ConfigRepository(private val context: Context) {
/**
* Get all peers (with enabled/disabled state)
* Returns only custom saved peers, does NOT return defaults
*/
fun getAllPeersInfo(): List<PeerInfo> {
return try {
val peersJson = prefs.getString(KEY_PEERS_V2, null)
if (peersJson.isNullOrEmpty()) {
// Return default peer as enabled
DEFAULT_PEERS.map { PeerInfo(it, isEnabled = true, tag = PeerInfo.PeerTag.DEFAULT) }
// Return empty list - defaults should be handled by getEnabledPeers()
emptyList()
} else {
val jsonArray = JSONArray(peersJson)
val peersList = mutableListOf<PeerInfo>()
for (i in 0 until jsonArray.length()) {
peersList.add(PeerInfo.fromJson(jsonArray.getJSONObject(i)))
}
// If empty and using defaults, return default peers
if (peersList.isEmpty() && isUsingDefaultPeers()) {
DEFAULT_PEERS.map { PeerInfo(it, isEnabled = true, tag = PeerInfo.PeerTag.DEFAULT) }
} else {
peersList
}
peersList
}
} catch (e: Exception) {
Log.e(TAG, "Error getting peers v2", e)
DEFAULT_PEERS.map { PeerInfo(it, isEnabled = true, tag = PeerInfo.PeerTag.DEFAULT) }
emptyList()
}
}
@@ -234,7 +235,11 @@ class ConfigRepository(private val context: Context) {
peers.add(peer)
}
savePeersV2(peers)
setUseDefaultPeers(false)
// Only disable default peers if saving a custom peer
if (peer.tag != PeerInfo.PeerTag.DEFAULT) {
setUseDefaultPeers(false)
}
}
/**
@@ -279,18 +284,26 @@ class ConfigRepository(private val context: Context) {
/**
* Get only enabled peers as strings
* Prioritizes custom peers over defaults
*/
fun getEnabledPeers(): List<String> {
// First, get all custom saved peers
val customPeers = getAllPeersInfo()
.filter { it.isEnabled }
.map { it.uri }
// If there are any enabled custom peers, use ONLY them (ignore defaults)
if (customPeers.isNotEmpty()) {
return customPeers
}
// No custom peers - check if we should use defaults
return if (isUsingDefaultPeers()) {
DEFAULT_PEERS
} else {
val enabledPeers = getAllPeersInfo()
.filter { it.isEnabled }
.map { it.uri }
// If no enabled peers and not using defaults, return empty list
// No custom peers and not using defaults - return empty list
// This allows multicast-only mode
enabledPeers
emptyList()
}
}
@@ -431,4 +444,90 @@ class ConfigRepository(private val context: Context) {
fun setTheme(theme: String) {
prefs.edit { putString(KEY_THEME, theme) }
}
// ============================================
// Discovered Peers Caching
// ============================================
/**
* Get cached discovered peers if within TTL
* @return List of discovered peers or null if cache is expired/empty
*/
fun getCachedDiscoveredPeers(): List<DiscoveredPeer>? {
return try {
val cachedJson = prefs.getString(KEY_CACHED_DISCOVERED_PEERS, null)
val timestamp = prefs.getLong(KEY_CACHE_TIMESTAMP, 0)
if (cachedJson.isNullOrEmpty() || timestamp == 0L) {
return null
}
// Check TTL
val currentTime = System.currentTimeMillis()
val cacheAge = currentTime - timestamp
val cacheTTL = CACHE_TTL_HOURS * 60 * 60 * 1000L // Convert hours to milliseconds
if (cacheAge > cacheTTL) {
Log.d(TAG, "Discovered peers cache expired (age: ${cacheAge / 1000 / 60 / 60}h)")
return null
}
// Parse JSON array
val jsonArray = JSONArray(cachedJson)
val peers = mutableListOf<DiscoveredPeer>()
for (i in 0 until jsonArray.length()) {
peers.add(DiscoveredPeer.fromJson(jsonArray.getJSONObject(i)))
}
Log.d(TAG, "Retrieved ${peers.size} cached discovered peers (age: ${cacheAge / 1000 / 60}min)")
peers
} catch (e: Exception) {
Log.e(TAG, "Error getting cached discovered peers", e)
null
}
}
/**
* Cache discovered peers with current timestamp
* @param peers List of discovered peers to cache
*/
fun cacheDiscoveredPeers(peers: List<DiscoveredPeer>) {
try {
val jsonArray = JSONArray()
peers.forEach { peer ->
val json = org.json.JSONObject().apply {
put("address", peer.address)
put("protocol", peer.protocol)
put("region", peer.region)
put("rtt", peer.rtt)
put("available", peer.available)
put("response_ms", peer.responseMs)
put("last_seen", peer.lastSeen)
}
jsonArray.put(json)
}
prefs.edit {
putString(KEY_CACHED_DISCOVERED_PEERS, jsonArray.toString())
putLong(KEY_CACHE_TIMESTAMP, System.currentTimeMillis())
}
Log.d(TAG, "Cached ${peers.size} discovered peers")
} catch (e: Exception) {
Log.e(TAG, "Error caching discovered peers", e)
}
}
/**
* Clear cached discovered peers
*/
fun clearCachedDiscoveredPeers() {
prefs.edit {
remove(KEY_CACHED_DISCOVERED_PEERS)
remove(KEY_CACHE_TIMESTAMP)
}
Log.d(TAG, "Cleared discovered peers cache")
}
}

View File

@@ -0,0 +1,106 @@
package com.jbselfcompany.tyr.data
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import org.json.JSONObject
/**
* Represents a discovered Yggdrasil peer from the public peer list
*/
data class DiscoveredPeer(
val address: String, // "tls://host:port"
val protocol: String, // "tcp", "tls", "quic", "ws", "wss"
val region: String, // "germany", "france", etc.
val rtt: Long, // RTT in milliseconds
val available: Boolean,
val responseMs: Int, // Response time from publicnodes.json
val lastSeen: Long // Unix timestamp
) {
/**
* Get formatted RTT string (e.g., "45ms")
*/
fun getRttFormatted(): String {
return "${rtt}ms"
}
/**
* Convert to PeerInfo for use with existing configuration
*/
fun toPeerInfo(): PeerInfo {
return PeerInfo(
uri = address,
isEnabled = true,
tag = PeerInfo.PeerTag.CUSTOM
)
}
companion object {
/**
* Parse DiscoveredPeer from JSON object
*/
fun fromJson(json: JSONObject): DiscoveredPeer {
return DiscoveredPeer(
address = json.getString("address"),
protocol = json.getString("protocol"),
region = json.optString("region", ""),
rtt = json.getLong("rtt"),
available = json.getBoolean("available"),
responseMs = json.getInt("response_ms"),
lastSeen = json.getLong("last_seen")
)
}
}
}
/**
* Network utility functions for peer discovery
*/
object NetworkUtils {
/**
* Get optimal batching parameters based on network type
* Returns Triple(batchSize, concurrency, pauseMs)
*
* Default values match yggpeers.DefaultBatchSize/Concurrency/PauseMs (40, 20, 150)
* WiFi/Ethernet: (30, 25, 150) - more aggressive for fast connections
* Mobile: (15, 10, 250) - conservative for battery saving
* Default: (40, 20, 150) - balanced for unknown network types
*/
fun getBatchingParams(context: Context): Triple<Int, Int, Int> {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return Triple(40, 20, 150) // Default from yggpeers
val network = connectivityManager.activeNetwork ?: return Triple(40, 20, 150)
val capabilities = connectivityManager.getNetworkCapabilities(network)
?: return Triple(40, 20, 150)
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ->
Triple(30, 25, 150) // WiFi - more aggressive
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ->
Triple(15, 10, 250) // Mobile - conservative for battery
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ->
Triple(30, 25, 150) // Ethernet - aggressive like WiFi
else ->
Triple(40, 20, 150) // Default from yggpeers
}
}
/**
* Check if network is available
*/
fun isNetworkAvailable(context: Context): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return false
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}

View File

@@ -30,36 +30,63 @@ class MaintenanceReceiver : BroadcastReceiver() {
* Compatible with Doze Mode via setExactAndAllowWhileIdle().
*/
fun scheduleMaintenance(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, MaintenanceReceiver::class.java).apply {
action = ACTION_MAINTENANCE
}
val pendingIntent = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Calculate next trigger time
val triggerTime = System.currentTimeMillis() + MAINTENANCE_INTERVAL_MS
// Use setExactAndAllowWhileIdle for Doze Mode compatibility
// This guarantees execution even in Doze Mode (up to 9 times per 15 min window)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
try {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, MaintenanceReceiver::class.java).apply {
action = ACTION_MAINTENANCE
}
val pendingIntent = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
Log.d(TAG, "Scheduled maintenance in 15 minutes (Doze-compatible)")
} else {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
)
Log.d(TAG, "Scheduled maintenance in 15 minutes")
// Calculate next trigger time
val triggerTime = System.currentTimeMillis() + MAINTENANCE_INTERVAL_MS
// Check if we can schedule exact alarms (Android 12+)
val canScheduleExactAlarms = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
alarmManager.canScheduleExactAlarms()
} else {
true
}
if (!canScheduleExactAlarms) {
Log.w(TAG, "Cannot schedule exact alarms - permission not granted. Using inexact alarm.")
// Fallback to inexact alarm (will work but less precise)
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
)
Log.d(TAG, "Scheduled maintenance in ~15 minutes (inexact)")
return
}
// Use setExactAndAllowWhileIdle for Doze Mode compatibility
// This guarantees execution even in Doze Mode (up to 9 times per 15 min window)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
)
Log.d(TAG, "Scheduled maintenance in 15 minutes (Doze-compatible)")
} else {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
)
Log.d(TAG, "Scheduled maintenance in 15 minutes")
}
} catch (e: SecurityException) {
// Android 12+ may throw SecurityException if SCHEDULE_EXACT_ALARM not granted
Log.e(TAG, "SecurityException scheduling maintenance - exact alarm permission not granted", e)
// Service will continue to work, just without precise maintenance scheduling
} catch (e: Exception) {
Log.e(TAG, "Error scheduling maintenance", e)
}
}
@@ -67,18 +94,22 @@ class MaintenanceReceiver : BroadcastReceiver() {
* Cancel scheduled maintenance.
*/
fun cancelMaintenance(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, MaintenanceReceiver::class.java).apply {
action = ACTION_MAINTENANCE
try {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, MaintenanceReceiver::class.java).apply {
action = ACTION_MAINTENANCE
}
val pendingIntent = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
Log.d(TAG, "Maintenance scheduling cancelled")
} catch (e: Exception) {
Log.e(TAG, "Error cancelling maintenance", e)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
Log.d(TAG, "Maintenance scheduling cancelled")
}
}

View File

@@ -69,7 +69,14 @@ class YggmailService : Service(), LogCallback {
val intent = Intent(context, YggmailService::class.java).apply {
action = ACTION_START
}
context.startForegroundService(intent)
try {
context.startForegroundService(intent)
} catch (e: Exception) {
// Android 12+ may throw ForegroundServiceStartNotAllowedException
// if app is in background or doesn't meet other foreground service requirements
Log.e(TAG, "Failed to start foreground service", e)
// Service will not start, but we don't crash the app
}
}
/**
@@ -123,6 +130,10 @@ class YggmailService : Service(), LogCallback {
private var lastError: String? = null
private val statusListeners = mutableListOf<ServiceStatusListener>()
// Connection status tracking
private var lastConnectionStatus: String? = null
private var connectionCheckRunnable: Runnable? = null
// Battery optimization state
private var isAppActive = false // Track if app is in foreground
private var isCharging = false // Track if device is charging
@@ -345,8 +356,18 @@ class YggmailService : Service(), LogCallback {
* Start foreground service with notification
*/
private fun startForegroundWithNotification() {
val notification = createNotification(ServiceStatus.STARTING)
startForeground(NOTIFICATION_ID, notification)
try {
val notification = createNotification(ServiceStatus.STARTING)
startForeground(NOTIFICATION_ID, notification)
} catch (e: Exception) {
// Handle potential exceptions from startForeground()
// (e.g., on Android 12+ if foreground service type restrictions are violated)
Log.e(TAG, "Failed to start foreground with notification", e)
// Update status to ERROR and stop service
lastError = "Failed to start foreground service: ${e.message}"
updateStatus(ServiceStatus.ERROR)
stopSelf()
}
}
/**
@@ -419,6 +440,9 @@ class YggmailService : Service(), LogCallback {
isRunning = true
updateStatus(ServiceStatus.RUNNING)
// Start periodic connection status check for notification updates
startConnectionStatusCheck()
} catch (e: Exception) {
Log.e(TAG, "Failed to start Yggmail service", e)
lastError = e.message
@@ -446,6 +470,9 @@ class YggmailService : Service(), LogCallback {
* Includes comprehensive panic/crash recovery for native library issues
*/
private fun stopYggmailSync() {
// Stop periodic connection status check
stopConnectionStatusCheck()
// Prevent concurrent stop operations
synchronized(this) {
if (!isRunning) {
@@ -714,9 +741,77 @@ class YggmailService : Service(), LogCallback {
}
}
/**
* Get connection status based on peer connections
*/
private fun getConnectionStatus(): String {
return when (serviceStatus) {
ServiceStatus.STARTING -> getString(R.string.connection_connecting)
ServiceStatus.STOPPING -> getString(R.string.service_stopping)
ServiceStatus.STOPPED -> getString(R.string.connection_offline)
ServiceStatus.ERROR -> lastError ?: getString(R.string.service_error)
ServiceStatus.RUNNING -> {
// Check if any peers are connected
val connections = getPeerConnections()
val hasConnectedPeer = connections?.any { it.up } == true
if (hasConnectedPeer) {
getString(R.string.connection_online)
} else {
getString(R.string.connection_offline)
}
}
}
}
/**
* Start periodic connection status check to update notification
* Checks every 30 seconds if connection status has changed
*/
private fun startConnectionStatusCheck() {
stopConnectionStatusCheck() // Clear any existing checks
connectionCheckRunnable = object : Runnable {
override fun run() {
try {
val currentStatus = getConnectionStatus()
if (currentStatus != lastConnectionStatus) {
lastConnectionStatus = currentStatus
// Update notification on main thread
mainHandler.post {
val notification = createNotification(serviceStatus)
notificationManager.notify(NOTIFICATION_ID, notification)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error checking connection status", e)
}
// Schedule next check in 30 seconds
if (serviceStatus == ServiceStatus.RUNNING) {
mainHandler.postDelayed(this, 30_000)
}
}
}
// Start first check after 5 seconds (give time for connections to establish)
mainHandler.postDelayed(connectionCheckRunnable!!, 5_000)
}
/**
* Stop periodic connection status check
*/
private fun stopConnectionStatusCheck() {
connectionCheckRunnable?.let {
mainHandler.removeCallbacks(it)
}
connectionCheckRunnable = null
lastConnectionStatus = null
}
/**
* Create notification for current service status
* Optimized for low battery usage with PRIORITY_MIN
* Shows connection status based on peer connections instead of service status
*/
private fun createNotification(status: ServiceStatus): Notification {
val intent = Intent(this, MainActivity::class.java).apply {
@@ -727,13 +822,7 @@ class YggmailService : Service(), LogCallback {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val statusText = when (status) {
ServiceStatus.STARTING -> getString(R.string.service_starting)
ServiceStatus.RUNNING -> getString(R.string.service_running)
ServiceStatus.STOPPING -> getString(R.string.service_stopping)
ServiceStatus.STOPPED -> getString(R.string.service_stopped)
ServiceStatus.ERROR -> lastError ?: getString(R.string.service_error)
}
val statusText = getConnectionStatus()
return NotificationCompat.Builder(this, TyrApplication.CHANNEL_ID_SERVICE)
.setContentTitle(statusText)
@@ -1019,6 +1108,78 @@ class YggmailService : Service(), LogCallback {
}
}
/**
* Set peer discovery batching parameters
* @param batchSize Number of peers to check in each batch
* @param concurrency Number of concurrent checks
* @param pauseMs Pause duration between batches in milliseconds
*/
fun setPeerBatchingParams(batchSize: Int, concurrency: Int, pauseMs: Int) {
serviceHandler.post {
try {
yggmailService?.setPeerBatchingParams(batchSize.toLong(), concurrency.toLong(), pauseMs.toLong())
Log.d(TAG, "Peer batching params set: batchSize=$batchSize, concurrency=$concurrency, pauseMs=$pauseMs")
} catch (e: Exception) {
Log.e(TAG, "Error setting peer batching params", e)
}
}
}
/**
* Find available peers asynchronously
* @param protocols Comma-separated protocol list (e.g., "tcp,tls,quic")
* @param region Region filter (empty for all regions)
* @param maxRTTMs Maximum RTT in milliseconds
* @param callback Callback for progress and results
*/
fun findAvailablePeersAsync(
protocols: String,
region: String,
maxRTTMs: Int,
callback: mobile.PeerDiscoveryCallback
) {
serviceHandler.post {
try {
yggmailService?.findAvailablePeersAsync(protocols, region, maxRTTMs.toLong(), callback)
Log.d(TAG, "Peer discovery started: protocols=$protocols, region=$region, maxRTT=${maxRTTMs}ms")
} catch (e: Exception) {
Log.e(TAG, "Error starting peer discovery", e)
}
}
}
/**
* Get available regions for peer filtering
* @return JSON array of region names
*/
fun getAvailableRegions(): String? {
val latch = CountDownLatch(1)
var result: String? = null
var error: Exception? = null
serviceHandler.post {
try {
result = yggmailService?.availableRegions
} catch (e: Exception) {
error = e
} finally {
latch.countDown()
}
}
if (!latch.await(10, TimeUnit.SECONDS)) {
Log.e(TAG, "Timeout getting available regions")
return null
}
if (error != null) {
Log.e(TAG, "Error getting available regions", error)
return null
}
return result
}
/**
* Soft stop: Gracefully disconnect peers before stopping the service
* This method disconnects all peers cleanly to avoid ErrClosed errors in logs

View File

@@ -0,0 +1,176 @@
package com.jbselfcompany.tyr.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.checkbox.MaterialCheckBox
import com.google.android.material.chip.Chip
import com.jbselfcompany.tyr.R
import com.jbselfcompany.tyr.data.DiscoveredPeer
/**
* Adapter for displaying discovered peers with checkbox selection
*/
class DiscoveredPeerAdapter(
private val onSelectionChanged: (Int) -> Unit
) : RecyclerView.Adapter<DiscoveredPeerAdapter.DiscoveredPeerViewHolder>() {
private val peers = mutableListOf<DiscoveredPeer>()
private val selectedPeers = mutableSetOf<String>() // Track by address
inner class DiscoveredPeerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val checkbox: MaterialCheckBox = itemView.findViewById(R.id.checkbox_peer)
val textAddress: TextView = itemView.findViewById(R.id.text_peer_address)
val chipRtt: Chip = itemView.findViewById(R.id.chip_rtt)
val chipRegion: Chip = itemView.findViewById(R.id.chip_region)
init {
// Handle checkbox click
checkbox.setOnCheckedChangeListener { _, isChecked ->
val position = adapterPosition
if (position != RecyclerView.NO_POSITION) {
val peer = peers[position]
if (isChecked) {
selectedPeers.add(peer.address)
} else {
selectedPeers.remove(peer.address)
}
onSelectionChanged(selectedPeers.size)
}
}
// Handle item click (toggle checkbox)
itemView.setOnClickListener {
checkbox.isChecked = !checkbox.isChecked
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DiscoveredPeerViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_discovered_peer, parent, false)
return DiscoveredPeerViewHolder(view)
}
override fun onBindViewHolder(holder: DiscoveredPeerViewHolder, position: Int) {
val peer = peers[position]
// Set checkbox state without triggering listener
holder.checkbox.setOnCheckedChangeListener(null)
holder.checkbox.isChecked = selectedPeers.contains(peer.address)
holder.checkbox.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
selectedPeers.add(peer.address)
} else {
selectedPeers.remove(peer.address)
}
onSelectionChanged(selectedPeers.size)
}
// Set peer info
holder.textAddress.text = peer.address
holder.chipRtt.text = peer.getRttFormatted()
// Show region chip only if region is not empty
if (peer.region.isNotEmpty()) {
holder.chipRegion.visibility = View.VISIBLE
holder.chipRegion.text = peer.region.replaceFirstChar { it.uppercase() }
} else {
holder.chipRegion.visibility = View.GONE
}
}
override fun getItemCount(): Int = peers.size
/**
* Update peer list with diff util for smooth animations
*/
fun updatePeers(newPeers: List<DiscoveredPeer>) {
val diffCallback = DiscoveredPeerDiffCallback(peers, newPeers)
val diffResult = DiffUtil.calculateDiff(diffCallback)
peers.clear()
peers.addAll(newPeers)
diffResult.dispatchUpdatesTo(this)
}
/**
* Add a single peer in real-time and keep the list sorted by RTT
* Note: This method uses notifyItemInserted which does NOT cause auto-scrolling.
* The RecyclerView will maintain the user's current scroll position.
*/
fun addPeerSorted(peer: DiscoveredPeer) {
// Check if peer already exists
if (peers.any { it.address == peer.address }) {
return
}
// Find insertion position (binary search for better performance)
val insertPosition = peers.binarySearch { it.rtt.compareTo(peer.rtt) }.let {
if (it < 0) -(it + 1) else it
}
// Insert at the correct position
peers.add(insertPosition, peer)
// Notify about insertion without triggering auto-scroll
notifyItemInserted(insertPosition)
}
/**
* Select all peers
*/
fun selectAll() {
selectedPeers.clear()
selectedPeers.addAll(peers.map { it.address })
notifyDataSetChanged()
onSelectionChanged(selectedPeers.size)
}
/**
* Deselect all peers
*/
fun deselectAll() {
selectedPeers.clear()
notifyDataSetChanged()
onSelectionChanged(0)
}
/**
* Get selected peers
*/
fun getSelectedPeers(): List<DiscoveredPeer> {
return peers.filter { selectedPeers.contains(it.address) }
}
/**
* Get selection count
*/
fun getSelectionCount(): Int = selectedPeers.size
/**
* DiffUtil callback for efficient list updates
*/
private class DiscoveredPeerDiffCallback(
private val oldList: List<DiscoveredPeer>,
private val newList: List<DiscoveredPeer>
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition].address == newList[newItemPosition].address
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val old = oldList[oldItemPosition]
val new = newList[newItemPosition]
return old == new
}
}
}

View File

@@ -464,8 +464,13 @@ class MainActivity : BaseActivity(), ServiceStatusListener {
* Latency information comes directly from Yggdrasil transport layer
*/
private fun startNetworkMonitoring() {
Log.d("MainActivity", "Starting network monitoring")
networkStatsMonitor.start(object : NetworkStatsMonitor.NetworkStatsListener {
override fun onStatsUpdated(stats: NetworkStatsMonitor.NetworkStats) {
Log.d("MainActivity", "onStatsUpdated called with ${stats.peers.size} peers")
stats.peers.forEach { peer ->
Log.d("MainActivity", " Peer: ${peer.host}:${peer.port}, connected=${peer.connected}, latencyMs=${peer.latencyMs}")
}
updateNetworkStatsUI(stats)
}
}, enableLatencyMeasurement = true) // Parameter kept for API compatibility
@@ -482,6 +487,7 @@ class MainActivity : BaseActivity(), ServiceStatusListener {
* Update UI with network statistics
*/
private fun updateNetworkStatsUI(stats: NetworkStatsMonitor.NetworkStats) {
Log.d("MainActivity", "updateNetworkStatsUI called, updating UI")
// Connection type
binding.textConnectionType.text = stats.connectionType
@@ -493,6 +499,7 @@ class MainActivity : BaseActivity(), ServiceStatusListener {
* Update the list of peers with latency information
*/
private fun updatePeersList(peers: List<NetworkStatsMonitor.PeerInfo>) {
Log.d("MainActivity", "updatePeersList called with ${peers.size} peers")
// Clear existing views
binding.peersContainer.removeAllViews()

View File

@@ -10,6 +10,7 @@ import android.os.IBinder
import android.os.Looper
import android.view.MenuItem
import android.view.View
import android.widget.PopupMenu
import android.widget.Toast
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@@ -33,6 +34,16 @@ class PeersActivity : BaseActivity(), ServiceStatusListener {
private lateinit var adapter: PeerAdapter
private val mainHandler = Handler(Looper.getMainLooper())
private val discoveredPeers = mutableListOf<com.jbselfcompany.tyr.data.DiscoveredPeer>()
private var searchInProgress = false
private var discoveryDialog: androidx.appcompat.app.AlertDialog? = null
private var discoveryAdapter: DiscoveredPeerAdapter? = null
private var discoveryProgressBar: com.google.android.material.progressindicator.LinearProgressIndicator? = null
private var discoveryProgressText: android.widget.TextView? = null
private var progressAnimator: android.animation.ValueAnimator? = null
private var currentProgress = 0
private var discoveryRecyclerView: androidx.recyclerview.widget.RecyclerView? = null
private var yggmailService: YggmailService? = null
private var serviceBound = false
private var wasServiceRunning = false
@@ -127,8 +138,23 @@ class PeersActivity : BaseActivity(), ServiceStatusListener {
}
private fun setupAddButton() {
binding.btnAddPeer.setOnClickListener {
showAddPeerDialog()
binding.btnAddPeer.setOnClickListener { view ->
val popup = PopupMenu(this, view)
popup.menuInflater.inflate(R.menu.menu_add_peer, popup.menu)
popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_add_manually -> {
showAddPeerDialog()
true
}
R.id.action_find_peers -> {
startPeerDiscovery()
true
}
else -> false
}
}
popup.show()
}
}
@@ -333,6 +359,205 @@ class PeersActivity : BaseActivity(), ServiceStatusListener {
}
}
private fun startPeerDiscovery() {
// Check network availability
if (!com.jbselfcompany.tyr.data.NetworkUtils.isNetworkAvailable(this)) {
showNoNetworkDialog()
return
}
if (searchInProgress) {
Toast.makeText(this, R.string.peer_discovery_in_progress, Toast.LENGTH_SHORT).show()
return
}
// Clear previous results
discoveredPeers.clear()
searchInProgress = true
// Show discovered peers dialog immediately with real-time updates
showDiscoveredPeersDialogRealtime()
// Set batching parameters based on network type
val (batchSize, concurrency, pauseMs) = com.jbselfcompany.tyr.data.NetworkUtils.getBatchingParams(this)
// Create callback
val callback = object : mobile.PeerDiscoveryCallback {
override fun onProgress(current: Long, total: Long, availableCount: Long) {
mainHandler.post {
if (discoveryProgressBar == null || discoveryProgressText == null) return@post
// Update progress bar with smooth animation (1% increments)
val percentage = if (total > 0) ((current * 100) / total).toInt() else 0
animateProgressTo(percentage)
}
}
override fun onPeerAvailable(peerJSON: String) {
mainHandler.post {
try {
val peer = com.jbselfcompany.tyr.data.DiscoveredPeer.fromJson(
org.json.JSONObject(peerJSON)
)
discoveredPeers.add(peer)
// Add peer to adapter in real-time with sorting by RTT
discoveryAdapter?.addPeerSorted(peer)
// Always scroll to top to show the fastest peers
discoveryRecyclerView?.scrollToPosition(0)
} catch (e: Exception) {
// Ignore malformed JSON
}
}
}
}
// Start async discovery using helper (doesn't require running service)
com.jbselfcompany.tyr.utils.PeerDiscoveryHelper.findAvailablePeersAsync(
context = this,
protocols = "tcp,tls,quic,ws,wss,unix,socks,sockstls", // All protocols
region = "", // All regions
maxRTTMs = 500, // Max 500ms RTT
callback = callback,
batchSize = batchSize,
concurrency = concurrency,
pauseMs = pauseMs
)
// Set timeout
mainHandler.postDelayed({
if (searchInProgress) {
finishDiscovery()
}
}, 60000) // 60 seconds timeout
}
private fun finishDiscovery() {
searchInProgress = false
// Update progress bar to 100% with animation
animateProgressTo(100)
if (discoveredPeers.isEmpty()) {
Toast.makeText(this, R.string.peer_discovery_no_peers_found, Toast.LENGTH_LONG).show()
discoveryDialog?.dismiss()
return
}
Toast.makeText(
this,
getString(R.string.peer_discovery_found, discoveredPeers.size),
Toast.LENGTH_SHORT
).show()
}
private fun finishDiscoveryWithError(error: String) {
searchInProgress = false
discoveryDialog?.dismiss()
Toast.makeText(
this,
getString(R.string.peer_discovery_error, error),
Toast.LENGTH_LONG
).show()
}
private fun showNoNetworkDialog() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.peer_discovery_no_network)
.setMessage(R.string.peer_discovery_no_network_message)
.setPositiveButton(R.string.ok, null)
.show()
}
private fun showDiscoveredPeersDialogRealtime() {
val dialogView = layoutInflater.inflate(R.layout.dialog_discovered_peers_realtime, null)
val recyclerView = dialogView.findViewById<androidx.recyclerview.widget.RecyclerView>(
R.id.recycler_discovered_peers
)
discoveryProgressBar = dialogView.findViewById(R.id.progress_discovery)
discoveryProgressText = dialogView.findViewById(R.id.text_progress)
discoveryRecyclerView = recyclerView
// Reset progress
currentProgress = 0
progressAnimator?.cancel()
discoveryProgressBar?.progress = 0
discoveryProgressText?.text = getString(R.string.peer_discovery_progress_percent, 0)
discoveryAdapter = DiscoveredPeerAdapter { selectionCount ->
// Update selection count if needed
}
val layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this@PeersActivity)
recyclerView.apply {
this.layoutManager = layoutManager
adapter = discoveryAdapter
// Add divider between items for better visual separation
addItemDecoration(
DividerItemDecoration(
this@PeersActivity,
DividerItemDecoration.VERTICAL
)
)
}
discoveryDialog = MaterialAlertDialogBuilder(this, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
.setTitle(R.string.peer_discovery_discovered_peers)
.setView(dialogView)
.setPositiveButton(R.string.peer_add_selected) { _, _ ->
val selectedPeers = discoveryAdapter?.getSelectedPeers() ?: emptyList()
addDiscoveredPeers(selectedPeers)
cleanupDiscovery()
}
.setNegativeButton(R.string.cancel) { _, _ ->
cleanupDiscovery()
}
.setOnDismissListener {
cleanupDiscovery()
}
.show()
}
private fun addDiscoveredPeers(peersToAdd: List<com.jbselfcompany.tyr.data.DiscoveredPeer>) {
if (peersToAdd.isEmpty()) {
Toast.makeText(this, R.string.error_no_peers_selected, Toast.LENGTH_SHORT).show()
return
}
var addedCount = 0
// Sort by RTT before adding (fastest first)
val sortedPeers = peersToAdd.sortedBy { it.rtt }
sortedPeers.forEach { discoveredPeer ->
// Convert to PeerInfo
val peerInfo = discoveredPeer.toPeerInfo()
// Check for duplicates
if (!peers.any { it.uri == peerInfo.uri }) {
peers.add(peerInfo)
configRepository.savePeer(peerInfo)
addedCount++
}
}
if (addedCount > 0) {
adapter.notifyDataSetChanged()
hasUnsavedChanges = true
updateApplyButtonVisibility()
Toast.makeText(
this,
getString(R.string.peers_added, addedCount),
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(this, R.string.all_peers_already_exist, Toast.LENGTH_SHORT).show()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
@@ -342,4 +567,44 @@ class PeersActivity : BaseActivity(), ServiceStatusListener {
else -> super.onOptionsItemSelected(item)
}
}
/**
* Animate progress bar smoothly from current value to target (1% increments)
* Speed: 300ms per 1% for consistent, visible animation
*/
private fun animateProgressTo(targetProgress: Int) {
if (discoveryProgressBar == null || discoveryProgressText == null) return
// Cancel previous animation
progressAnimator?.cancel()
// Create smooth animation with consistent speed (300ms per 1%)
progressAnimator = android.animation.ValueAnimator.ofInt(currentProgress, targetProgress).apply {
duration = ((targetProgress - currentProgress) * 300).toLong().coerceAtLeast(100)
interpolator = android.view.animation.LinearInterpolator()
addUpdateListener { animator ->
val value = animator.animatedValue as Int
currentProgress = value
discoveryProgressBar?.setProgress(value, false)
discoveryProgressText?.text = getString(R.string.peer_discovery_progress_percent, value)
}
start()
}
}
/**
* Cleanup discovery state
*/
private fun cleanupDiscovery() {
searchInProgress = false
progressAnimator?.cancel()
progressAnimator = null
currentProgress = 0
discoveryAdapter = null
discoveryProgressBar = null
discoveryProgressText = null
discoveryRecyclerView = null
}
}

View File

@@ -84,27 +84,17 @@ class OnboardingActivity : BaseActivity(), OnRestoreCompletedListener {
binding.viewPager.currentItem = currentPage - 1
}
}
binding.buttonSkip.setOnClickListener {
completeOnboarding()
}
}
private fun updateButtons(position: Int) {
binding.buttonBack.isEnabled = position > 0
when (position) {
0 -> {
binding.buttonNext.text = getString(R.string.next)
binding.buttonSkip.visibility = android.view.View.VISIBLE
}
adapter.itemCount - 1 -> {
2 -> {
binding.buttonNext.text = getString(R.string.finish)
binding.buttonSkip.visibility = android.view.View.GONE
}
else -> {
binding.buttonNext.text = getString(R.string.next)
binding.buttonSkip.visibility = android.view.View.GONE
}
}
}
@@ -141,6 +131,41 @@ class OnboardingActivity : BaseActivity(), OnRestoreCompletedListener {
}
}
}
is OnboardingPeersFragment -> {
val selectedPeers = currentFragment.getSelectedPeers()
val useDefault = currentFragment.isUsingDefaultPeers()
when {
selectedPeers.isEmpty() && !useDefault -> {
Toast.makeText(this, R.string.error_no_peers_selected, Toast.LENGTH_SHORT).show()
false
}
else -> {
if (selectedPeers.isNotEmpty()) {
// User selected custom peers - save them and disable defaults
// (savePeer() automatically sets useDefaultPeers to false)
selectedPeers.sortedBy { it.rtt }.forEach { peer ->
configRepository.savePeer(peer.toPeerInfo())
}
} else {
// User chose to use default peers - save them as PeerInfo with DEFAULT tag
// This will preserve the useDefaultPeers flag since savePeer checks the tag
com.jbselfcompany.tyr.data.ConfigRepository.DEFAULT_PEERS.forEach { peerUri ->
val defaultPeer = com.jbselfcompany.tyr.data.PeerInfo(
uri = peerUri,
isEnabled = true,
tag = com.jbselfcompany.tyr.data.PeerInfo.PeerTag.DEFAULT
)
configRepository.savePeer(defaultPeer)
}
// Explicitly set the flag to true after saving default peers
configRepository.setUseDefaultPeers(true)
}
true
}
}
}
else -> true
}
}

View File

@@ -9,12 +9,13 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
*/
class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = 2
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> OnboardingWelcomeFragment()
1 -> OnboardingPasswordFragment()
2 -> OnboardingPeersFragment()
else -> throw IllegalArgumentException("Invalid position: $position")
}
}

View File

@@ -1,22 +1,41 @@
package com.jbselfcompany.tyr.ui.onboarding
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.jbselfcompany.tyr.R
import com.jbselfcompany.tyr.TyrApplication
import com.jbselfcompany.tyr.data.ConfigRepository
import com.jbselfcompany.tyr.data.DiscoveredPeer
import com.jbselfcompany.tyr.data.NetworkUtils
import com.jbselfcompany.tyr.databinding.FragmentOnboardingPeersBinding
import com.jbselfcompany.tyr.ui.DiscoveredPeerAdapter
import mobile.PeerDiscoveryCallback
/**
* Peers configuration fragment for onboarding
* Peers configuration fragment for onboarding with peer discovery
*/
class OnboardingPeersFragment : Fragment() {
private var _binding: FragmentOnboardingPeersBinding? = null
private val binding get() = _binding!!
private val configRepository by lazy { TyrApplication.instance.configRepository }
private val discoveredPeers = mutableListOf<DiscoveredPeer>()
private lateinit var adapter: DiscoveredPeerAdapter
private val mainHandler = Handler(Looper.getMainLooper())
private var useDefaultPeers = false
private var searchInProgress = false
private var progressAnimator: android.animation.ValueAnimator? = null
private var currentProgress = 0
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -29,23 +48,265 @@ class OnboardingPeersFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Pre-fill with default peers (this fragment is no longer used in onboarding)
val defaultPeers = ConfigRepository.DEFAULT_PEERS.joinToString("\n")
binding.editPeers.setText(defaultPeers)
setupRecyclerView()
setupButtons()
loadCachedPeers()
}
override fun onDestroyView() {
super.onDestroyView()
progressAnimator?.cancel()
progressAnimator = null
_binding = null
}
private fun setupRecyclerView() {
adapter = DiscoveredPeerAdapter { selectionCount ->
// Selection changed callback - could update UI if needed
}
binding.recyclerPeers.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = this@OnboardingPeersFragment.adapter
// Add divider between items for better visual separation
addItemDecoration(
androidx.recyclerview.widget.DividerItemDecoration(
requireContext(),
androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
)
)
}
}
private fun setupButtons() {
binding.buttonFindPeers.setOnClickListener {
startPeerDiscovery()
}
binding.switchUseDefault.setOnCheckedChangeListener { _, isChecked ->
useDefaultPeers = isChecked
if (isChecked) {
// Clear selected peers and hide peer card when using defaults
discoveredPeers.clear()
adapter.updatePeers(discoveredPeers)
binding.peersCard.visibility = View.GONE
binding.chipCacheIndicator.visibility = View.GONE
}
}
}
private fun loadCachedPeers() {
val cachedPeers = configRepository.getCachedDiscoveredPeers()
if (cachedPeers != null && cachedPeers.isNotEmpty()) {
discoveredPeers.clear()
discoveredPeers.addAll(cachedPeers)
adapter.updatePeers(discoveredPeers)
// Show cache indicator and hide instructions
binding.chipCacheIndicator.visibility = View.VISIBLE
binding.peersCard.visibility = View.VISIBLE
binding.instructionsCard.visibility = View.GONE
}
}
private fun startPeerDiscovery() {
// Check network availability
if (!NetworkUtils.isNetworkAvailable(requireContext())) {
showNoNetworkDialog()
return
}
if (searchInProgress) {
Toast.makeText(requireContext(), R.string.peer_discovery_in_progress, Toast.LENGTH_SHORT).show()
return
}
// Clear previous results
discoveredPeers.clear()
adapter.updatePeers(discoveredPeers)
// Hide cache indicator and instructions
binding.chipCacheIndicator.visibility = View.GONE
binding.instructionsCard.visibility = View.GONE
// Show progress and peers card
binding.progressLayout.visibility = View.VISIBLE
binding.peersCard.visibility = View.VISIBLE
binding.buttonFindPeers.isEnabled = false
binding.switchUseDefault.isEnabled = false
// Turn off default peers switch when starting discovery
binding.switchUseDefault.isChecked = false
// Reset progress
currentProgress = 0
progressAnimator?.cancel()
binding.progressBar.progress = 0
binding.textProgress.text = getString(R.string.peer_discovery_progress_percent, 0)
searchInProgress = true
// Set batching parameters based on network type
val (batchSize, concurrency, pauseMs) = NetworkUtils.getBatchingParams(requireContext())
// Create callback
val callback = object : PeerDiscoveryCallback {
override fun onProgress(current: Long, total: Long, availableCount: Long) {
mainHandler.post {
updateProgress(current.toInt(), total.toInt(), availableCount.toInt())
}
}
override fun onPeerAvailable(peerJSON: String) {
mainHandler.post {
try {
val peer = DiscoveredPeer.fromJson(org.json.JSONObject(peerJSON))
addDiscoveredPeer(peer)
} catch (e: Exception) {
// Ignore malformed JSON
}
}
}
}
// Start async discovery using helper (doesn't require running service)
com.jbselfcompany.tyr.utils.PeerDiscoveryHelper.findAvailablePeersAsync(
context = requireContext(),
protocols = "tcp,tls,quic,ws,wss,unix,socks,sockstls", // All protocols
region = "", // All regions
maxRTTMs = 500, // Max 500ms RTT
callback = callback,
batchSize = batchSize,
concurrency = concurrency,
pauseMs = pauseMs
)
// Set timeout
mainHandler.postDelayed({
if (searchInProgress) {
finishDiscovery()
}
}, 60000) // 60 seconds timeout
}
private fun updateProgress(current: Int, total: Int, availableCount: Int) {
if (_binding == null) return
// Update progress bar with smooth animation (1% increments)
val progress = if (total > 0) (current * 100) / total else 0
animateProgressTo(progress)
// Auto-finish when complete
if (current >= total && total > 0) {
finishDiscovery()
}
}
private fun addDiscoveredPeer(peer: DiscoveredPeer) {
if (_binding == null) return
discoveredPeers.add(peer)
// Add peer dynamically with sorting by RTT
adapter.addPeerSorted(peer)
// Always scroll to top to show the fastest peers
binding.recyclerPeers.scrollToPosition(0)
}
private fun finishDiscovery() {
if (_binding == null) return
searchInProgress = false
// Hide progress
binding.progressLayout.visibility = View.GONE
binding.buttonFindPeers.isEnabled = true
binding.switchUseDefault.isEnabled = true
if (discoveredPeers.isEmpty()) {
binding.peersCard.visibility = View.GONE
binding.instructionsCard.visibility = View.VISIBLE
Toast.makeText(requireContext(), R.string.peer_discovery_no_peers_found, Toast.LENGTH_LONG).show()
return
}
// Peers are already added dynamically via addPeerSorted(), no need to update adapter
// Show peers card
binding.peersCard.visibility = View.VISIBLE
// Cache the results
configRepository.cacheDiscoveredPeers(discoveredPeers)
Toast.makeText(
requireContext(),
getString(R.string.peer_discovery_found, discoveredPeers.size),
Toast.LENGTH_SHORT
).show()
}
private fun showNoNetworkDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.peer_discovery_no_network)
.setMessage(R.string.peer_discovery_no_network_message)
.setPositiveButton(R.string.use_default_peers) { _, _ ->
// Enable the default peers switch
binding.switchUseDefault.isChecked = true
}
.setNegativeButton(R.string.cancel, null)
.show()
}
/**
* Get list of peers entered by user
* Get selected peers for validation in OnboardingActivity
*/
fun getPeers(): List<String> {
val peersText = binding.editPeers.text.toString()
return peersText.split("\n")
.map { it.trim() }
.filter { it.isNotEmpty() }
fun getSelectedPeers(): List<DiscoveredPeer> {
return adapter.getSelectedPeers()
}
/**
* Check if user chose to use default peers
*/
fun isUsingDefaultPeers(): Boolean {
return useDefaultPeers
}
/**
* Validate that either peers are selected or default is chosen
*/
fun hasValidSelection(): Boolean {
return useDefaultPeers || adapter.getSelectedPeers().isNotEmpty()
}
/**
* Animate progress bar smoothly from current value to target (1% increments)
* Speed: 300ms per 1% for consistent, visible animation
*/
private fun animateProgressTo(targetProgress: Int) {
if (_binding == null) return
// Cancel previous animation
progressAnimator?.cancel()
// Create smooth animation with consistent speed (300ms per 1%)
progressAnimator = android.animation.ValueAnimator.ofInt(currentProgress, targetProgress).apply {
duration = ((targetProgress - currentProgress) * 300).toLong().coerceAtLeast(100)
interpolator = android.view.animation.LinearInterpolator()
addUpdateListener { animator ->
if (_binding == null) {
cancel()
return@addUpdateListener
}
val value = animator.animatedValue as Int
currentProgress = value
binding.progressBar.setProgress(value, false)
binding.textProgress.text = getString(R.string.peer_discovery_progress_percent, value)
}
start()
}
}
}

View File

@@ -65,7 +65,10 @@ class NetworkStatsMonitor(private val context: Context) {
*/
fun start(listener: NetworkStatsListener, enableLatencyMeasurement: Boolean = true) {
if (isMonitoring) {
Log.w(TAG, "Already monitoring")
// Already monitoring - just update the listener
Log.d(TAG, "Already monitoring, updating listener")
this.listener = listener
this.measureLatency = enableLatencyMeasurement
return
}
@@ -142,6 +145,10 @@ class NetworkStatsMonitor(private val context: Context) {
// Notify listener on main thread
handler.post {
Log.d(TAG, "Calling listener with ${peers.size} peers (listener is ${if (listener != null) "not null" else "null"})")
peers.forEach { peer ->
Log.d(TAG, " Peer data: ${peer.host}:${peer.port}, connected=${peer.connected}, latencyMs=${peer.latencyMs}")
}
listener?.onStatsUpdated(stats)
}

View File

@@ -0,0 +1,77 @@
package com.jbselfcompany.tyr.utils
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import mobile.PeerDiscoveryCallback
import java.io.File
/**
* Helper class for peer discovery that doesn't require a running service.
* Creates a temporary YggmailService instance just for peer discovery.
*/
object PeerDiscoveryHelper {
private const val TAG = "PeerDiscoveryHelper"
/**
* Start peer discovery asynchronously without requiring a running service
*
* @param context Application context
* @param protocols Comma-separated protocol list (e.g., "tcp,tls,quic")
* @param region Region filter (empty for all regions)
* @param maxRTTMs Maximum RTT in milliseconds
* @param callback Callback for progress and results
* @param batchSize Batch size for peer checking (default: 20, optimal for 10-100 Mbps)
* @param concurrency Number of concurrent peer checks (default: 20)
* @param pauseMs Pause between batches in milliseconds (default: 200)
*/
fun findAvailablePeersAsync(
context: Context,
protocols: String,
region: String,
maxRTTMs: Int,
callback: PeerDiscoveryCallback,
batchSize: Int = 20,
concurrency: Int = 20,
pauseMs: Int = 200
) {
Thread {
try {
// Create a temporary YggmailService instance just for peer discovery
// We use a temporary in-memory database path that doesn't need to exist
val tempDbPath = File(context.cacheDir, "temp_peer_discovery.db").absolutePath
val tempSmtpAddr = "127.0.0.1:0" // Port 0 = don't bind
val tempImapAddr = "127.0.0.1:0" // Port 0 = don't bind
val tempService = mobile.Mobile.newYggmailService(
tempDbPath,
tempSmtpAddr,
tempImapAddr
)
if (tempService == null) {
Log.e(TAG, "Failed to create temporary service for peer discovery")
return@Thread
}
// Set batching parameters
tempService.setPeerBatchingParams(batchSize.toLong(), concurrency.toLong(), pauseMs.toLong())
// Start peer discovery (doesn't require Initialize() or Start())
tempService.findAvailablePeersAsync(protocols, region, maxRTTMs.toLong(), callback)
Log.d(TAG, "Peer discovery started: protocols=$protocols, region=$region, maxRTT=${maxRTTMs}ms")
} catch (e: Exception) {
Log.e(TAG, "Error starting peer discovery", e)
// Notify callback about error
Handler(Looper.getMainLooper()).post {
// Signal completion with 0 results
callback.onProgress(0, 0, 0)
}
}
}.start()
}
}

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,8l-4,4h3c0,3.31 -2.69,6 -6,6 -1.01,0 -1.97,-0.25 -2.8,-0.7l-1.46,1.46C8.97,19.54 10.43,20 12,20c4.42,0 8,-3.58 8,-8h3l-4,-4zM6,12c0,-3.31 2.69,-6 6,-6 1.01,0 1.97,0.25 2.8,0.7l1.46,-1.46C15.03,4.46 13.57,4 12,4c-4.42,0 -8,3.58 -8,8H1l4,4 4,-4H6z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M20.38,8.57l-1.23,1.85a8,8 0,0 1,-0.22 7.58H5.07A8,8 0,0 1,15.58 6.85l1.85,-1.23A10,10 0,0 0,3.35 19a2,2 0,0 0,1.72 1h13.85a2,2 0,0 0,1.74 -1,10 10,0 0,0 -0.27,-10.44zM10.59,15.41a2,2 0,0 0,2.83 0l5.66,-8.49 -8.49,5.66a2,2 0,0 0,0 2.83z" />
</vector>

View File

@@ -65,14 +65,6 @@
android:textSize="16sp"
app:cornerRadius="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_skip"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:text="@string/skip" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,179 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- Toolbar -->
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
android:elevation="4dp"
app:title="@string/peer_discovery_discovered_peers"
app:navigationIcon="@drawable/ic_back" />
<!-- Filter chips -->
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none"
android:background="?attr/colorSurfaceVariant">
<com.google.android.material.chip.ChipGroup
android:id="@+id/chip_group_filters"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp"
app:singleLine="true"
app:selectionRequired="false">
<com.google.android.material.chip.Chip
android:id="@+id/chip_filter_all"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.Material3.Chip.Filter"
android:text="@string/peer_filter_all"
android:checked="true" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_filter_tls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.Material3.Chip.Filter"
android:text="TLS" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_filter_quic"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.Material3.Chip.Filter"
android:text="QUIC" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_filter_tcp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.Material3.Chip.Filter"
android:text="TCP" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>
<!-- Selection info bar -->
<LinearLayout
android:id="@+id/selection_info_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:background="?attr/colorPrimaryContainer"
android:gravity="center_vertical"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/text_selection_count"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceBody1"
android:textColor="?attr/colorOnPrimaryContainer"
tools:text="3 selected" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_select_all"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/peer_select_all"
style="@style/Widget.Material3.Button.TextButton"
app:icon="@drawable/ic_check_circle" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_deselect_all"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/peer_deselect_all"
style="@style/Widget.Material3.Button.TextButton" />
</LinearLayout>
<!-- Peer list -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_discovered_peers"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="8dp"
tools:listitem="@layout/item_discovered_peer" />
<!-- Empty state -->
<LinearLayout
android:id="@+id/empty_state"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="32dp"
android:visibility="gone">
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:src="@drawable/ic_search"
android:alpha="0.5"
android:layout_marginBottom="16dp"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/peer_discovery_no_peers_found"
android:textAppearance="?attr/textAppearanceBody1"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center" />
</LinearLayout>
</FrameLayout>
<!-- Bottom action bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:background="?attr/colorSurface"
android:elevation="8dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/text_total_count"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?attr/colorOnSurfaceVariant"
tools:text="45 peers available" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_add_selected"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/peer_add_selected"
app:icon="@drawable/ic_check_circle"
style="@style/Widget.Material3.Button" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- Progress bar showing percentage -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:cardCornerRadius="16dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurfaceVariant">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/text_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="12dp"
tools:text="Searching: 45%" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_discovery"
android:layout_width="match_parent"
android:layout_height="8dp"
android:indeterminate="false"
android:max="100"
android:progress="0"
app:trackCornerRadius="4dp"
app:trackThickness="8dp"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceContainer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Peer list -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_discovered_peers"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:scrollbars="vertical"
android:fadeScrollbars="false"
tools:listitem="@layout/item_discovered_peer" />
<!-- Empty state -->
<LinearLayout
android:id="@+id/empty_state"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="32dp"
android:visibility="gone">
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:src="@drawable/ic_search"
android:alpha="0.5"
android:layout_marginBottom="16dp"
app:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/peer_discovery_no_peers_found"
android:textAppearance="?attr/textAppearanceBody1"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center" />
</LinearLayout>
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/text_dialog_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/peer_discovery_searching"
android:textAppearance="?attr/textAppearanceHeadline6"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="16dp" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progress_search"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:indeterminate="true"
android:layout_marginBottom="16dp"
app:indicatorSize="48dp" />
<TextView
android:id="@+id/text_progress_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="?attr/textAppearanceBody1"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp"
tools:text="Checking 150 / 300 peers..." />
<TextView
android:id="@+id/text_found_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?attr/colorPrimary"
android:textStyle="bold"
android:layout_marginBottom="16dp"
tools:text="Found: 45 peers" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@android:string/cancel"
style="@style/Widget.Material3.Button.TextButton" />
</LinearLayout>

View File

@@ -1,99 +1,213 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/onboarding_horizontal_padding">
<!-- Header Section -->
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:letterSpacing="0.01"
android:text="@string/configure_peers"
android:textSize="@dimen/text_size_onboarding_title"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
android:textStyle="bold" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:alpha="0.87"
android:lineSpacingExtra="2dp"
android:text="@string/peers_description"
android:textSize="@dimen/text_size_onboarding_description"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" />
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Peers Input with Modern Styling -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/peers_layout"
style="@style/Widget.Material3.TextInputLayout.FilledBox"
android:layout_width="0dp"
<!-- Action Buttons -->
<com.google.android.material.button.MaterialButton
android:id="@+id/button_find_peers"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:hint="@string/yggdrasil_peers"
app:boxCornerRadiusBottomEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusTopStart="16dp"
app:counterEnabled="true"
app:counterMaxLength="1000"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/description">
android:layout_marginTop="24dp"
android:text="@string/peer_find_peers"
app:icon="@drawable/ic_search"
style="@style/Widget.Material3.Button" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_peers"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:gravity="top|start"
android:inputType="textMultiLine"
android:minLines="8"
android:paddingTop="20dp"
android:paddingBottom="20dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textSize="13sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Info Card -->
<!-- Default Peers Switch -->
<com.google.android.material.card.MaterialCardView
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:cardBackgroundColor="?attr/colorTertiaryContainer"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/peers_layout">
android:layout_marginTop="16dp"
app:cardCornerRadius="12dp"
app:cardElevation="1dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant"
app:cardBackgroundColor="?attr/colorSurfaceContainer">
<TextView
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:text="@string/peers_info"
android:textSize="14sp"
android:textColor="?attr/colorOnTertiaryContainer" />
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/use_default_peers"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/use_default_peers_description"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_use_default"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Cache Indicator Chip -->
<com.google.android.material.chip.Chip
android:id="@+id/chip_cache_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/peer_from_cache"
android:visibility="gone"
style="@style/Widget.Material3.Chip.Assist"
app:chipIcon="@drawable/ic_cached"
tools:visibility="visible" />
<!-- Progress Section -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/progress_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:cardCornerRadius="16dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
tools:visibility="visible">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/text_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="12dp"
tools:text="Searching: 45%" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="8dp"
android:indeterminate="false"
android:max="100"
android:progress="0"
app:trackCornerRadius="4dp"
app:trackThickness="8dp"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceContainer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Initial Instructions Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/instructions_card"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="16dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="?attr/colorSurfaceVariant">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/peer_onboarding_instructions"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:lineSpacingExtra="4dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Discovered Peers RecyclerView with scrolling -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/peers_card"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="16dp"
android:visibility="gone"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_peers"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp"
android:clipToPadding="false"
android:scrollbars="vertical"
android:fadeScrollbars="false"
tools:visibility="visible"
tools:listitem="@layout/item_discovered_peer" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardElevation="2dp"
app:cardCornerRadius="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/checkbox_peer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/text_peer_address"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface"
android:ellipsize="middle"
android:singleLine="true"
tools:text="tls://example.yggdrasil.network:12345" />
<com.google.android.material.chip.ChipGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:singleLine="true">
<com.google.android.material.chip.Chip
android:id="@+id/chip_rtt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.Material3.Chip.Assist"
app:chipIcon="@drawable/ic_speed"
app:chipIconSize="16dp"
app:chipMinHeight="28dp"
android:textSize="12sp"
tools:text="45ms" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_region"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.Material3.Chip.Assist"
app:chipIcon="@drawable/ic_location"
app:chipIconSize="16dp"
app:chipMinHeight="28dp"
android:textSize="12sp"
tools:text="Germany" />
</com.google.android.material.chip.ChipGroup>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_add_manually"
android:title="@string/peer_add_manually"
android:icon="@android:drawable/ic_input_add" />
<item
android:id="@+id/action_find_peers"
android:title="@string/peer_find_peers"
android:icon="@drawable/ic_search" />
</menu>

View File

@@ -37,6 +37,11 @@
<string name="service_error">Ошибка</string>
<string name="service_error_desc">Не удалось запустить службу</string>
<!-- Connection Status (for notification) -->
<string name="connection_online">В сети</string>
<string name="connection_offline">Не в сети</string>
<string name="connection_connecting">Подключение…</string>
<!-- Status Display -->
<string name="status_starting">Запуск…</string>
<string name="status_running">Работает</string>
@@ -110,6 +115,8 @@
<string name="configure_peers_title">Настроить пиры</string>
<string name="configure_peers_description">Управление подключениями к пирам сети Yggdrasil</string>
<string name="use_default_peers">Использовать пиры по умолчанию</string>
<string name="use_default_peers_description">Подключаться используя встроенный список пиров</string>
<string name="peer_onboarding_instructions">Выберите способ подключения к сети Yggdrasil:\n\n• Включите переключатель для использования пиров по умолчанию\n• Или нажмите \"Поиск пиров\" для обнаружения доступных пиров</string>
<string name="custom_peers">Пользовательские пиры</string>
<string name="add_peer">Добавить пир</string>
<string name="edit_peer">Редактировать пир</string>
@@ -266,4 +273,31 @@
• Material Components (Apache 2.0)\n
• И другие…
</string>
<!-- Peer Discovery -->
<string name="peer_add_manually">Добавить вручную</string>
<string name="peer_find_peers">Найти пиры</string>
<string name="peer_discovery_searching">Поиск пиров…</string>
<string name="peer_discovery_progress_percent">Поиск: %d%%</string>
<string name="peer_discovery_discovered_peers">Найденные пиры</string>
<string name="peer_discovery_no_peers_found">Пиры не найдены</string>
<string name="peer_filter_all">Все</string>
<string name="peer_select_all">Выбрать все</string>
<string name="peer_deselect_all">Снять выделение</string>
<string name="peer_add_selected">Добавить выбранные</string>
<string name="peer_discovery_progress">Проверка %1$d / %2$d пиров…</string>
<string name="peer_discovery_progress_detailed">Найдено %1$d из %2$d пиров (всего %3$d)</string>
<string name="peer_discovery_found">Найдено: %d пиров</string>
<string name="peer_discovery_total_available">Доступно пиров: %d</string>
<string name="peer_discovery_selected">Выбрано: %d</string>
<string name="peer_added">Успешно добавлено пиров: %d</string>
<string name="peers_added">Добавлено пиров: %d</string>
<string name="peer_discovery_error">Не удалось найти пиры: %s</string>
<string name="peer_discovery_no_network">Нет подключения к сети</string>
<string name="peer_discovery_no_network_message">Невозможно найти пиры без подключения к интернету. Используйте дефолтные пиры или добавьте вручную.</string>
<string name="peer_discovery_in_progress">Поиск пиров уже выполняется</string>
<string name="peer_from_cache">Из кэша (24ч)</string>
<string name="using_default_peers">Используются дефолтные пиры</string>
<string name="all_peers_already_exist">Все выбранные пиры уже существуют</string>
<string name="error_no_peers_selected">Пожалуйста, выберите хотя бы один пир или используйте дефолтные пиры</string>
</resources>

View File

@@ -37,6 +37,11 @@
<string name="service_error">Error</string>
<string name="service_error_desc">Failed to start service</string>
<!-- Connection Status (for notification) -->
<string name="connection_online">Online</string>
<string name="connection_offline">Offline</string>
<string name="connection_connecting">Connecting…</string>
<!-- Status Display -->
<string name="status_starting">Starting…</string>
<string name="status_running">Running</string>
@@ -109,6 +114,8 @@
<string name="configure_peers_title">Configure Peers</string>
<string name="configure_peers_description">Manage Yggdrasil network peer connections</string>
<string name="use_default_peers">Use default peers</string>
<string name="use_default_peers_description">Connect using built-in peer list</string>
<string name="peer_onboarding_instructions">Choose how to connect to the Yggdrasil network:\n\n• Enable the switch to use default peers\n• Or tap \"Find Peers\" to discover available peers</string>
<string name="custom_peers">Custom Peers</string>
<string name="add_peer">Add Peer</string>
<string name="edit_peer">Edit Peer</string>
@@ -265,4 +272,31 @@
• Material Components (Apache 2.0)\n
• And others…
</string>
<!-- Peer Discovery -->
<string name="peer_add_manually">Add Manually</string>
<string name="peer_find_peers">Find Peers</string>
<string name="peer_discovery_searching">Searching for peers…</string>
<string name="peer_discovery_progress_percent">Searching: %d%%</string>
<string name="peer_discovery_discovered_peers">Discovered Peers</string>
<string name="peer_discovery_no_peers_found">No peers found</string>
<string name="peer_filter_all">All</string>
<string name="peer_select_all">Select All</string>
<string name="peer_deselect_all">Clear Selection</string>
<string name="peer_add_selected">Add Selected</string>
<string name="peer_discovery_progress">Checking %1$d / %2$d peers…</string>
<string name="peer_discovery_progress_detailed">Found %1$d of %2$d peers (total %3$d)</string>
<string name="peer_discovery_found">Found: %d peers</string>
<string name="peer_discovery_total_available">%d peers available</string>
<string name="peer_discovery_selected">%d selected</string>
<string name="peer_added">%d peer(s) added successfully</string>
<string name="peers_added">%d peer(s) added</string>
<string name="peer_discovery_error">Failed to discover peers: %s</string>
<string name="peer_discovery_no_network">No network connection</string>
<string name="peer_discovery_no_network_message">Cannot search for peers without an internet connection. Use default peers or add manually.</string>
<string name="peer_discovery_in_progress">Peer discovery already in progress</string>
<string name="peer_from_cache">From cache (24h)</string>
<string name="using_default_peers">Using default peers</string>
<string name="all_peers_already_exist">All selected peers already exist</string>
<string name="error_no_peers_selected">Please select at least one peer or use default peers</string>
</resources>

View File

@@ -36,4 +36,9 @@
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<!-- AlertDialog with centered buttons -->
<style name="Theme.Tyr.AlertDialog.CenteredButtons" parent="ThemeOverlay.Material3.MaterialAlertDialog.Centered">
<item name="materialAlertDialogTitleTextStyle">@style/MaterialAlertDialog.Material3.Title.Text.CenterStacked</item>
</style>
</resources>

View File

@@ -0,0 +1 @@
• Added the option of auto-configuration of peers, sorting by RTT

View File

@@ -0,0 +1 @@
• Добавлена опция автоконфигурации пиров, сортировка по RTT