mirror of
https://github.com/JB-SelfCompany/Tyr.git
synced 2025-12-23 22:47:50 -05:00
Added the option of auto-configuration of peers, sorting by RTT
This commit is contained in:
@@ -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'
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
106
app/src/main/java/com/jbselfcompany/tyr/data/DiscoveredPeer.kt
Normal file
106
app/src/main/java/com/jbselfcompany/tyr/data/DiscoveredPeer.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
11
app/src/main/res/drawable/ic_back.xml
Normal file
11
app/src/main/res/drawable/ic_back.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_cached.xml
Normal file
9
app/src/main/res/drawable/ic_cached.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_location.xml
Normal file
10
app/src/main/res/drawable/ic_location.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_search.xml
Normal file
10
app/src/main/res/drawable/ic_search.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_speed.xml
Normal file
10
app/src/main/res/drawable/ic_speed.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
179
app/src/main/res/layout/dialog_discovered_peers.xml
Normal file
179
app/src/main/res/layout/dialog_discovered_peers.xml
Normal 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>
|
||||
99
app/src/main/res/layout/dialog_discovered_peers_realtime.xml
Normal file
99
app/src/main/res/layout/dialog_discovered_peers_realtime.xml
Normal 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>
|
||||
57
app/src/main/res/layout/dialog_find_peers.xml
Normal file
57
app/src/main/res/layout/dialog_find_peers.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
75
app/src/main/res/layout/item_discovered_peer.xml
Normal file
75
app/src/main/res/layout/item_discovered_peer.xml
Normal 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>
|
||||
11
app/src/main/res/menu/menu_add_peer.xml
Normal file
11
app/src/main/res/menu/menu_add_peer.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
1
fastlane/metadata/android/en-US/changelogs/20.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/20.txt
Normal file
@@ -0,0 +1 @@
|
||||
• Added the option of auto-configuration of peers, sorting by RTT
|
||||
1
fastlane/metadata/android/ru-RU/changelogs/20.txt
Normal file
1
fastlane/metadata/android/ru-RU/changelogs/20.txt
Normal file
@@ -0,0 +1 @@
|
||||
• Добавлена опция автоконфигурации пиров, сортировка по RTT
|
||||
Reference in New Issue
Block a user