🚀 feat: Production release preparation with GitHub Actions deployment

## Major Features
-  Battery optimized auto-sync (30 min interval, ~0.4%/day)
-  BuildConfig.DEBUG conditional logging (Logger.kt)
-  Settings UI cleanup (SSID field removed)
-  Interactive notifications (click opens app)
-  Post-reboot auto-sync (BootReceiver)
-  GitHub Actions deployment workflow

## Implementation Details

### Auto-Sync Architecture
- WorkManager PeriodicWorkRequest (30 min intervals)
- Gateway IP detection via network interface enumeration
- Smart sync only when on home network
- BootReceiver restarts monitoring after device reboot

### Logging System
- Logger.kt object with BuildConfig.DEBUG checks
- Debug logs only in DEBUG builds
- Error/warning logs always visible
- All components updated (NetworkMonitor, SyncWorker, WebDavSyncService, etc.)

### UI Improvements
- Removed confusing SSID field from Settings
- Gateway detection fully automatic
- Material Design 3 info boxes
- Cleaner, simpler user interface

### Notifications
- PendingIntent opens MainActivity on click
- setAutoCancel(true) for auto-dismiss
- Broadcast receiver for UI refresh on sync

### GitHub Actions
- Automated APK builds on push to main
- Signed releases with proper keystore
- 3 APK variants (universal, arm64-v8a, armeabi-v7a)
- Semantic versioning: YYYY.MM.DD + build number
- Comprehensive release notes with installation guide

## Documentation
- README.md: User-friendly German guide
- DOCS.md: Technical architecture documentation
- GITHUB_ACTIONS_SETUP.md: Deployment setup guide

## Build Configuration
- Signing support via key.properties
- APK splits for smaller downloads
- ProGuard enabled with resource shrinking
- BuildConfig generation for DEBUG flag
This commit is contained in:
inventory69
2025-12-21 11:09:29 +01:00
parent 933646f28b
commit 7e277e7fb9
19 changed files with 1866 additions and 562 deletions

View File

@@ -1,19 +1,25 @@
package dev.dettmer.simplenotes
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import dev.dettmer.simplenotes.utils.Logger
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.floatingactionbutton.FloatingActionButton
import dev.dettmer.simplenotes.adapters.NotesAdapter
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.showToast
import android.widget.TextView
@@ -34,9 +40,28 @@ class MainActivity : AppCompatActivity() {
private val storage by lazy { NotesStorage(this) }
companion object {
private const val TAG = "MainActivity"
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
}
/**
* BroadcastReceiver für Background-Sync Completion
*/
private val syncCompletedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val success = intent?.getBooleanExtra("success", false) ?: false
val count = intent?.getIntExtra("count", 0) ?: 0
Logger.d(TAG, "📡 Sync completed broadcast received: success=$success, count=$count")
// UI refresh
if (success && count > 0) {
loadNotes()
Logger.d(TAG, "🔄 Notes reloaded after background sync")
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
@@ -56,9 +81,25 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
// Register BroadcastReceiver für Background-Sync
LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver,
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
)
Logger.d(TAG, "📡 BroadcastReceiver registered")
loadNotes()
}
override fun onPause() {
super.onPause()
// Unregister BroadcastReceiver
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
}
private fun findViews() {
recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
textViewEmpty = findViewById(R.id.textViewEmpty)

View File

@@ -1,50 +1,48 @@
package dev.dettmer.simplenotes
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.view.MenuItem
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.appbar.MaterialToolbar
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.showToast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
class SettingsActivity : AppCompatActivity() {
companion object {
private const val TAG = "SettingsActivity"
}
private lateinit var editTextServerUrl: EditText
private lateinit var editTextUsername: EditText
private lateinit var editTextPassword: EditText
private lateinit var editTextHomeSSID: EditText
private lateinit var switchAutoSync: SwitchCompat
private lateinit var buttonTestConnection: Button
private lateinit var buttonSyncNow: Button
private lateinit var buttonDetectSSID: Button
private lateinit var textViewServerStatus: TextView
private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
}
companion object {
private const val REQUEST_LOCATION_PERMISSION = 1002
private const val REQUEST_BACKGROUND_LOCATION_PERMISSION = 1003
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
@@ -66,19 +64,20 @@ class SettingsActivity : AppCompatActivity() {
editTextServerUrl = findViewById(R.id.editTextServerUrl)
editTextUsername = findViewById(R.id.editTextUsername)
editTextPassword = findViewById(R.id.editTextPassword)
editTextHomeSSID = findViewById(R.id.editTextHomeSSID)
switchAutoSync = findViewById(R.id.switchAutoSync)
buttonTestConnection = findViewById(R.id.buttonTestConnection)
buttonSyncNow = findViewById(R.id.buttonSyncNow)
buttonDetectSSID = findViewById(R.id.buttonDetectSSID)
textViewServerStatus = findViewById(R.id.textViewServerStatus)
}
private fun loadSettings() {
editTextServerUrl.setText(prefs.getString(Constants.KEY_SERVER_URL, ""))
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
editTextHomeSSID.setText(prefs.getString(Constants.KEY_HOME_SSID, ""))
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
// Server Status prüfen
checkServerStatus()
}
private fun setupListeners() {
@@ -92,13 +91,16 @@ class SettingsActivity : AppCompatActivity() {
syncNow()
}
buttonDetectSSID.setOnClickListener {
detectCurrentSSID()
}
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked)
}
// Server Status Check bei Settings-Änderung
editTextServerUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
checkServerStatus()
}
}
}
private fun saveSettings() {
@@ -106,7 +108,6 @@ class SettingsActivity : AppCompatActivity() {
putString(Constants.KEY_SERVER_URL, editTextServerUrl.text.toString().trim())
putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim())
putString(Constants.KEY_PASSWORD, editTextPassword.text.toString().trim())
putString(Constants.KEY_HOME_SSID, editTextHomeSSID.text.toString().trim())
putBoolean(Constants.KEY_AUTO_SYNC, switchAutoSync.isChecked)
apply()
}
@@ -152,34 +153,41 @@ class SettingsActivity : AppCompatActivity() {
}
}
private fun detectCurrentSSID() {
// Check if we have location permission (needed for SSID on Android 10+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
// Request permission
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
REQUEST_LOCATION_PERMISSION
)
return
}
private fun checkServerStatus() {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) {
textViewServerStatus.text = "❌ Nicht konfiguriert"
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
return
}
// Permission granted, get SSID
val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
val wifiInfo = wifiManager.connectionInfo
val ssid = wifiInfo.ssid.replace("\"", "")
textViewServerStatus.text = "🔍 Prüfe..."
textViewServerStatus.setTextColor(getColor(android.R.color.darker_gray))
if (ssid.isNotEmpty() && ssid != "<unknown ssid>") {
editTextHomeSSID.setText(ssid)
showToast("SSID erkannt: $ssid")
} else {
showToast("Nicht mit WLAN verbunden")
lifecycleScope.launch {
val isReachable = withContext(Dispatchers.IO) {
try {
val url = URL(serverUrl)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 3000
connection.readTimeout = 3000
val code = connection.responseCode
connection.disconnect()
code in 200..299 || code == 401 // 401 = Server da, Auth fehlt
} catch (e: Exception) {
Log.e(TAG, "Server check failed: ${e.message}")
false
}
}
if (isReachable) {
textViewServerStatus.text = "✅ Erreichbar"
textViewServerStatus.setTextColor(getColor(android.R.color.holo_green_dark))
} else {
textViewServerStatus.text = "❌ Nicht erreichbar"
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
}
}
}
@@ -188,12 +196,11 @@ class SettingsActivity : AppCompatActivity() {
if (enabled) {
showToast("Auto-Sync aktiviert")
// Check battery optimization when enabling
checkBatteryOptimization()
// Check background location permission (needed for SSID on Android 12+)
checkBackgroundLocationPermission()
restartNetworkMonitor()
} else {
showToast("Auto-Sync deaktiviert")
restartNetworkMonitor()
}
}
@@ -240,99 +247,16 @@ class SettingsActivity : AppCompatActivity() {
}
}
private fun checkBackgroundLocationPermission() {
// Background location permission only needed on Android 10+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return
}
// First check if we have foreground location
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
// Request foreground location first
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
REQUEST_LOCATION_PERMISSION
)
return
}
// Now check background location (Android 10+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_BACKGROUND_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
showBackgroundLocationDialog()
}
}
}
private fun showBackgroundLocationDialog() {
AlertDialog.Builder(this)
.setTitle("Hintergrund-Standort")
.setMessage(
"Damit die App dein WLAN-Netzwerk erkennen kann, " +
"wird Zugriff auf den Standort im Hintergrund benötigt.\n\n" +
"Dies ist eine Android-Einschränkung ab Version 10.\n\n" +
"Bitte wähle im nächsten Dialog 'Immer zulassen'."
)
.setPositiveButton("Fortfahren") { _, _ ->
requestBackgroundLocationPermission()
}
.setNegativeButton("Später") { dialog, _ ->
dialog.dismiss()
showToast("Auto-Sync funktioniert ohne diese Berechtigung nicht")
}
.setCancelable(false)
.show()
}
private fun requestBackgroundLocationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
REQUEST_BACKGROUND_LOCATION_PERMISSION
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_LOCATION_PERMISSION -> {
if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Foreground location granted, now request background
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
checkBackgroundLocationPermission()
} else {
// For detectCurrentSSID
detectCurrentSSID()
}
} else {
showToast("Standort-Berechtigung benötigt um WLAN-Name zu erkennen")
}
}
REQUEST_BACKGROUND_LOCATION_PERMISSION -> {
if (grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
showToast("✅ Hintergrund-Standort erlaubt - Auto-Sync sollte jetzt funktionieren!")
} else {
showToast("⚠️ Ohne Hintergrund-Standort kann WLAN nicht erkannt werden")
}
}
private fun restartNetworkMonitor() {
try {
val app = application as SimpleNotesApplication
Log.d(TAG, "🔄 Restarting NetworkMonitor with new settings")
app.networkMonitor.stopMonitoring()
app.networkMonitor.startMonitoring()
Log.d(TAG, "✅ NetworkMonitor restarted successfully")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to restart NetworkMonitor", e)
showToast("Fehler beim Neustart des NetworkMonitors")
}
}

View File

@@ -1,7 +1,7 @@
package dev.dettmer.simplenotes
import android.app.Application
import android.util.Log
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.NotificationHelper
@@ -11,31 +11,34 @@ class SimpleNotesApplication : Application() {
private const val TAG = "SimpleNotesApp"
}
private lateinit var networkMonitor: NetworkMonitor
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
override fun onCreate() {
super.onCreate()
Log.d(TAG, "🚀 Application onCreate()")
Logger.d(TAG, "🚀 Application onCreate()")
// Initialize notification channel
NotificationHelper.createNotificationChannel(this)
Log.d(TAG, "✅ Notification channel created")
Logger.d(TAG, "✅ Notification channel created")
// Initialize and start NetworkMonitor at application level
// CRITICAL: Use applicationContext, not 'this'!
// Initialize NetworkMonitor (WorkManager-based)
// VORTEIL: WorkManager läuft auch ohne aktive App!
networkMonitor = NetworkMonitor(applicationContext)
// Start WorkManager periodic sync
// Dies läuft im Hintergrund auch wenn App geschlossen ist
networkMonitor.startMonitoring()
Log.d(TAG, "NetworkMonitor initialized and started")
Logger.d(TAG, "WorkManager-based auto-sync initialized")
}
override fun onTerminate() {
super.onTerminate()
Log.d(TAG, "🛑 Application onTerminate()")
Logger.d(TAG, "🛑 Application onTerminate()")
// Clean up NetworkMonitor when app is terminated
networkMonitor.stopMonitoring()
// WorkManager läuft weiter auch nach onTerminate!
// Nur bei deaktiviertem Auto-Sync stoppen wir es
}
}

View File

@@ -0,0 +1,44 @@
package dev.dettmer.simplenotes.sync
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
/**
* BootReceiver: Startet WorkManager nach Device Reboot
* CRITICAL: Ohne diesen Receiver funktioniert Auto-Sync nach Reboot NICHT!
*/
class BootReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "BootReceiver"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) {
Logger.w(TAG, "Received unexpected intent: ${intent.action}")
return
}
Logger.d(TAG, "📱 BOOT_COMPLETED received")
// Prüfe ob Auto-Sync aktiviert ist
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
if (!autoSyncEnabled) {
Logger.d(TAG, "❌ Auto-sync disabled - not starting WorkManager")
return
}
Logger.d(TAG, "🚀 Auto-sync enabled - starting WorkManager")
// WorkManager neu starten
val networkMonitor = NetworkMonitor(context.applicationContext)
networkMonitor.startMonitoring()
Logger.d(TAG, "✅ WorkManager started after boot")
}
}

View File

@@ -1,160 +1,86 @@
package dev.dettmer.simplenotes.sync
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.util.Log
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.*
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import java.util.concurrent.TimeUnit
/**
* NetworkMonitor: Verwaltet WorkManager-basiertes Auto-Sync
* WICHTIG: Kein NetworkCallback mehr - WorkManager macht das für uns!
*/
class NetworkMonitor(private val context: Context) {
companion object {
private const val TAG = "NetworkMonitor"
private const val AUTO_SYNC_WORK_NAME = "auto_sync_periodic"
}
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as ConnectivityManager
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d(TAG, "📶 Network available: $network")
val capabilities = connectivityManager.getNetworkCapabilities(network)
if (capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) {
Log.d(TAG, "✅ WiFi detected")
checkAndTriggerSync()
} else {
Log.d(TAG, "❌ Not WiFi: ${capabilities?.toString()}")
}
}
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, capabilities)
Log.d(TAG, "🔄 Capabilities changed: $network")
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
Log.d(TAG, "✅ WiFi capabilities")
checkAndTriggerSync()
}
}
override fun onLost(network: Network) {
super.onLost(network)
Log.d(TAG, "❌ Network lost: $network")
}
private val prefs by lazy {
context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
/**
* Startet WorkManager mit Network Constraints
* WorkManager kümmert sich automatisch um WiFi-Erkennung!
*/
fun startMonitoring() {
Log.d(TAG, "🚀 Starting NetworkMonitor")
Log.d(TAG, "Context type: ${context.javaClass.simpleName}")
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
try {
connectivityManager.registerNetworkCallback(request, networkCallback)
Log.d(TAG, "✅ NetworkCallback registered successfully")
// *** FIX #3: Check if already connected to WiFi ***
Log.d(TAG, "🔍 Performing initial WiFi check...")
checkAndTriggerSync()
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to register NetworkCallback: ${e.message}", e)
}
}
fun stopMonitoring() {
Log.d(TAG, "🛑 Stopping NetworkMonitor")
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
Log.d(TAG, "✅ NetworkCallback unregistered")
} catch (e: Exception) {
Log.w(TAG, "⚠️ NetworkCallback already unregistered: ${e.message}")
}
}
private fun checkAndTriggerSync() {
Log.d(TAG, "🔍 Checking auto-sync conditions...")
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
Log.d(TAG, "Auto-sync enabled: $autoSyncEnabled")
if (!autoSyncEnabled) {
Log.d(TAG, "Auto-sync disabled, skipping")
Logger.d(TAG, "Auto-sync disabled - stopping periodic work")
stopMonitoring()
return
}
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null)
Log.d(TAG, "Home SSID configured: $homeSSID")
Logger.d(TAG, "🚀 Starting WorkManager-based auto-sync")
if (isConnectedToHomeWifi()) {
Log.d(TAG, "✅ Connected to home WiFi, scheduling sync!")
scheduleSyncWork()
} else {
Log.d(TAG, "❌ Not connected to home WiFi")
}
// Constraints: Nur wenn WiFi connected
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only
.build()
// Periodic Work Request - prüft alle 30 Minuten (Battery optimized)
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
30, TimeUnit.MINUTES, // Optimiert: 30 Min statt 15 Min
10, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.addTag(Constants.SYNC_WORK_TAG)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
AUTO_SYNC_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE, // UPDATE statt KEEP für immediate trigger
syncRequest
)
Logger.d(TAG, "✅ Periodic auto-sync scheduled (every 30min when on WiFi)")
// Trigger sofortigen Sync wenn WiFi bereits connected
triggerImmediateSync()
}
private fun isConnectedToHomeWifi(): Boolean {
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
as WifiManager
val currentSSID = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Android 12+: Use WifiInfo from NetworkCapabilities
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val wifiInfo = capabilities.transportInfo as? WifiInfo
wifiInfo?.ssid?.replace("\"", "") ?: ""
} else {
wifiManager.connectionInfo.ssid.replace("\"", "")
}
} else {
wifiManager.connectionInfo.ssid.replace("\"", "")
}
Log.d(TAG, "Current SSID: '$currentSSID', Home SSID: '$homeSSID'")
// *** FIX #4: Better error handling for missing SSID ***
if (currentSSID.isEmpty() || currentSSID == "<unknown ssid>") {
Log.w(TAG, "⚠️ Cannot get SSID - likely missing ACCESS_BACKGROUND_LOCATION permission!")
Log.w(TAG, "⚠️ On Android 12+, apps need 'Allow all the time' location permission")
return false
}
val isHome = currentSSID == homeSSID
Log.d(TAG, "Is home WiFi: $isHome")
return isHome
/**
* Stoppt WorkManager Auto-Sync
*/
fun stopMonitoring() {
Logger.d(TAG, "🛑 Stopping auto-sync")
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
}
private fun scheduleSyncWork() {
Log.d(TAG, "📅 Scheduling sync work...")
/**
* Trigger sofortigen Sync (z.B. nach Settings-Änderung)
*/
private fun triggerImmediateSync() {
if (!isConnectedToHomeWifi()) {
Logger.d(TAG, "Not on home WiFi - skipping immediate sync")
return
}
Logger.d(TAG, "<EFBFBD> Triggering immediate sync...")
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
@@ -162,7 +88,72 @@ class NetworkMonitor(private val context: Context) {
.build()
WorkManager.getInstance(context).enqueue(syncRequest)
}
/**
* Prüft ob connected zu Home WiFi via Gateway IP Check
*/
private fun isConnectedToHomeWifi(): Boolean {
val gatewayIP = getGatewayIP() ?: return false
Log.d(TAG, "✅ Sync work scheduled with WorkManager")
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) return false
val serverIP = extractIPFromUrl(serverUrl)
if (serverIP == null) return false
val sameNetwork = isSameNetwork(gatewayIP, serverIP)
Logger.d(TAG, "Gateway: $gatewayIP, Server: $serverIP → Same network: $sameNetwork")
return sameNetwork
}
private fun getGatewayIP(): String? {
return try {
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
as WifiManager
val dhcpInfo = wifiManager.dhcpInfo
val gateway = dhcpInfo.gateway
val ip = String.format(
"%d.%d.%d.%d",
gateway and 0xFF,
(gateway shr 8) and 0xFF,
(gateway shr 16) and 0xFF,
(gateway shr 24) and 0xFF
)
ip
} catch (e: Exception) {
Logger.e(TAG, "Failed to get gateway IP: ${e.message}")
null
}
}
private fun extractIPFromUrl(url: String): String? {
return try {
val urlObj = java.net.URL(url)
val host = urlObj.host
if (host.matches(Regex("\\d+\\.\\d+\\.\\d+\\.\\d+"))) {
host
} else {
val addr = java.net.InetAddress.getByName(host)
addr.hostAddress
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to extract IP: ${e.message}")
null
}
}
private fun isSameNetwork(ip1: String, ip2: String): Boolean {
val parts1 = ip1.split(".")
val parts2 = ip2.split(".")
if (parts1.size != 4 || parts2.size != 4) return false
return parts1[0] == parts2[0] &&
parts1[1] == parts2[1] &&
parts1[2] == parts2[2]
}
}

View File

@@ -1,9 +1,11 @@
package dev.dettmer.simplenotes.sync
import android.content.Context
import android.util.Log
import android.content.Intent
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -15,44 +17,77 @@ class SyncWorker(
companion object {
private const val TAG = "SyncWorker"
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
Log.d(TAG, "🔄 SyncWorker started")
Logger.d(TAG, "🔄 SyncWorker started")
Logger.d(TAG, "Context: ${applicationContext.javaClass.simpleName}")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
return@withContext try {
// Show notification that sync is starting
NotificationHelper.showSyncInProgress(applicationContext)
Log.d(TAG, "📢 Notification shown: Sync in progress")
// Start sync
// Start sync (kein "in progress" notification mehr)
val syncService = WebDavSyncService(applicationContext)
Log.d(TAG, "🚀 Starting sync...")
Logger.d(TAG, "🚀 Starting sync...")
Logger.d(TAG, "📊 Attempt: ${runAttemptCount}")
val result = syncService.syncNotes()
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
if (result.isSuccess) {
Log.d(TAG, "✅ Sync successful: ${result.syncedCount} notes")
NotificationHelper.showSyncSuccess(
applicationContext,
result.syncedCount
)
Logger.d(TAG, "✅ Sync successful: ${result.syncedCount} notes")
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
if (result.syncedCount > 0) {
NotificationHelper.showSyncSuccess(
applicationContext,
result.syncedCount
)
} else {
Logger.d(TAG, " No changes to sync - no notification")
}
// **UI REFRESH**: Broadcast für MainActivity
broadcastSyncCompleted(true, result.syncedCount)
Result.success()
} else {
Log.e(TAG, "❌ Sync failed: ${result.errorMessage}")
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
NotificationHelper.showSyncError(
applicationContext,
result.errorMessage ?: "Unbekannter Fehler"
)
// Broadcast auch bei Fehler (damit UI refresht)
broadcastSyncCompleted(false, 0)
Result.failure()
}
} catch (e: Exception) {
Log.e(TAG, "💥 Sync exception: ${e.message}", e)
Logger.e(TAG, "💥 Sync exception: ${e.message}", e)
Logger.e(TAG, "Exception type: ${e.javaClass.name}")
Logger.e(TAG, "Stack trace:", e)
NotificationHelper.showSyncError(
applicationContext,
e.message ?: "Unknown error"
)
broadcastSyncCompleted(false, 0)
Result.failure()
}
}
/**
* Sendet Broadcast an MainActivity für UI Refresh
*/
private fun broadcastSyncCompleted(success: Boolean, count: Int) {
val intent = Intent(ACTION_SYNC_COMPLETED).apply {
putExtra("success", success)
putExtra("count", count)
}
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count")
}
}

View File

@@ -7,11 +7,16 @@ import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class WebDavSyncService(private val context: Context) {
companion object {
private const val TAG = "WebDavSyncService"
}
private val storage = NotesStorage(context)
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
@@ -19,6 +24,9 @@ class WebDavSyncService(private val context: Context) {
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
// Einfach standard OkHttpSardine - funktioniert im manuellen Sync!
android.util.Log.d(TAG, "🔧 Creating OkHttpSardine")
return OkHttpSardine().apply {
setCredentials(username, password)
}
@@ -75,37 +83,59 @@ class WebDavSyncService(private val context: Context) {
}
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) {
android.util.Log.d(TAG, "🔄 syncNotes() called")
android.util.Log.d(TAG, "Context: ${context.javaClass.simpleName}")
return@withContext try {
val sardine = getSardine() ?: return@withContext SyncResult(
isSuccess = false,
errorMessage = "Server-Zugangsdaten nicht konfiguriert"
)
val sardine = getSardine()
if (sardine == null) {
android.util.Log.e(TAG, "❌ Sardine is null - credentials missing")
return@withContext SyncResult(
isSuccess = false,
errorMessage = "Server-Zugangsdaten nicht konfiguriert"
)
}
val serverUrl = getServerUrl() ?: return@withContext SyncResult(
isSuccess = false,
errorMessage = "Server-URL nicht konfiguriert"
)
val serverUrl = getServerUrl()
if (serverUrl == null) {
android.util.Log.e(TAG, "Server URL is null")
return@withContext SyncResult(
isSuccess = false,
errorMessage = "Server-URL nicht konfiguriert"
)
}
android.util.Log.d(TAG, "📡 Server URL: $serverUrl")
android.util.Log.d(TAG, "🔐 Credentials configured: ${prefs.getString(Constants.KEY_USERNAME, null) != null}")
var syncedCount = 0
var conflictCount = 0
// Ensure server directory exists
android.util.Log.d(TAG, "🔍 Checking if server directory exists...")
if (!sardine.exists(serverUrl)) {
android.util.Log.d(TAG, "📁 Creating server directory...")
sardine.createDirectory(serverUrl)
}
// Upload local notes
android.util.Log.d(TAG, "⬆️ Uploading local notes...")
val uploadedCount = uploadLocalNotes(sardine, serverUrl)
syncedCount += uploadedCount
android.util.Log.d(TAG, "✅ Uploaded: $uploadedCount notes")
// Download remote notes
android.util.Log.d(TAG, "⬇️ Downloading remote notes...")
val downloadResult = downloadRemoteNotes(sardine, serverUrl)
syncedCount += downloadResult.downloadedCount
conflictCount += downloadResult.conflictCount
android.util.Log.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
// Update last sync timestamp
saveLastSyncTimestamp()
android.util.Log.d(TAG, "🎉 Sync completed successfully - Total synced: $syncedCount")
SyncResult(
isSuccess = true,
syncedCount = syncedCount,
@@ -113,11 +143,14 @@ class WebDavSyncService(private val context: Context) {
)
} catch (e: Exception) {
android.util.Log.e(TAG, "💥 Sync exception: ${e.message}", e)
android.util.Log.e(TAG, "Exception type: ${e.javaClass.name}")
SyncResult(
isSuccess = false,
errorMessage = when (e) {
is java.net.UnknownHostException -> "Server nicht erreichbar"
is java.net.SocketTimeoutException -> "Verbindungs-Timeout"
is java.net.UnknownHostException -> "Server nicht erreichbar: ${e.message}"
is java.net.SocketTimeoutException -> "Verbindungs-Timeout: ${e.message}"
is javax.net.ssl.SSLException -> "SSL-Fehler"
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
when (e.statusCode) {

View File

@@ -0,0 +1,42 @@
package dev.dettmer.simplenotes.utils
import android.util.Log
import dev.dettmer.simplenotes.BuildConfig
/**
* Logger: Debug logs nur bei DEBUG builds
* Release builds zeigen nur Errors/Warnings
*/
object Logger {
fun d(tag: String, message: String) {
if (BuildConfig.DEBUG) {
Log.d(tag, message)
}
}
fun v(tag: String, message: String) {
if (BuildConfig.DEBUG) {
Log.v(tag, message)
}
}
fun i(tag: String, message: String) {
if (BuildConfig.DEBUG) {
Log.i(tag, message)
}
}
// Errors und Warnings IMMER zeigen (auch in Release)
fun e(tag: String, message: String, throwable: Throwable? = null) {
if (throwable != null) {
Log.e(tag, message, throwable)
} else {
Log.e(tag, message)
}
}
fun w(tag: String, message: String) {
Log.w(tag, message)
}
}

View File

@@ -212,12 +212,25 @@ object NotificationHelper {
* Zeigt Erfolgs-Notification
*/
fun showSyncSuccess(context: Context, count: Int) {
// PendingIntent für App-Öffnung
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Sync erfolgreich")
.setContentText("$count Notizen synchronisiert")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(pendingIntent) // Click öffnet App
.setAutoCancel(true) // Dismiss beim Click
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
@@ -229,12 +242,25 @@ object NotificationHelper {
* Zeigt Fehler-Notification
*/
fun showSyncError(context: Context, message: String) {
// PendingIntent für App-Öffnung
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Sync Fehler")
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setContentIntent(pendingIntent) // Click öffnet App
.setAutoCancel(true) // Dismiss beim Click
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)