🚀 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

@@ -0,0 +1,198 @@
name: Build Android Production APK
on:
push:
branches: [ main ] # Trigger on push to main branch
workflow_dispatch: # Allow manual trigger
permissions:
contents: write # Required for creating releases
jobs:
build:
name: Build Production APK
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Generate Production version number
run: |
# Generate semantic version: YYYY.MM.DD
VERSION_NAME="$(date +'%Y.%m.%d')"
# Use GitHub run number as build number for production
BUILD_NUMBER="${{ github.run_number }}"
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
echo "VERSION_TAG=v$VERSION_NAME-prod.$BUILD_NUMBER" >> $GITHUB_ENV
echo "🚀 Generated PRODUCTION version: $VERSION_NAME+$BUILD_NUMBER"
- name: Update build.gradle.kts with Production version
run: |
# Update versionCode and versionName in build.gradle.kts
sed -i "s/versionCode = [0-9]*/versionCode = ${{ env.BUILD_NUMBER }}/" android/app/build.gradle.kts
sed -i "s/versionName = \".*\"/versionName = \"${{ env.VERSION_NAME }}\"/" android/app/build.gradle.kts
echo "✅ Updated build.gradle.kts:"
grep -E "versionCode|versionName" android/app/build.gradle.kts
- name: Setup Android signing
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
echo "storeFile=simple-notes-release.jks" >> android/key.properties
echo "✅ Signing configuration created"
- name: Build Production APK (Release)
run: |
cd android
./gradlew assembleRelease --no-daemon --stacktrace
- name: Copy APK variants to root with version names
run: |
mkdir -p apk-output
# Universal APK
cp android/app/build/outputs/apk/release/app-universal-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk
# ARM64 APK
cp android/app/build/outputs/apk/release/app-arm64-v8a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk
# ARMv7 APK
cp android/app/build/outputs/apk/release/app-armeabi-v7a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk
echo "✅ APK files prepared:"
ls -lh apk-output/
- name: Upload APK artifacts
uses: actions/upload-artifact@v4
with:
name: simple-notes-sync-apks-v${{ env.VERSION_NAME }}
path: apk-output/*.apk
retention-days: 90 # Keep production builds longer
- name: Get commit info
run: |
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV
# Get full commit message preserving newlines and emojis (UTF-8)
{
echo 'COMMIT_MSG<<EOF'
git -c core.quotepath=false log -1 --pretty=%B
echo 'EOF'
} >> $GITHUB_ENV
- name: Create Production Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ env.VERSION_TAG }}
name: "📝 Simple Notes Sync v${{ env.VERSION_NAME }} (Production)"
files: apk-output/*.apk
draft: false
prerelease: false
generate_release_notes: false
body: |
# 📝 Production Release: Simple Notes Sync v${{ env.VERSION_NAME }}
## Build Information
- **Version:** ${{ env.VERSION_NAME }}+${{ env.BUILD_NUMBER }}
- **Build Date:** ${{ env.COMMIT_DATE }}
- **Commit:** ${{ env.SHORT_SHA }}
- **Environment:** 🟢 **PRODUCTION**
---
## 📋 Changes
${{ env.COMMIT_MSG }}
---
## 📦 Download & Installation
### Which APK should I download?
| Your Device | Download This APK | Size | Compatibility |
|-------------|------------------|------|---------------|
| 🤷 Not sure? | `simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk` | ~5 MB | Works on all devices |
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk` | ~3 MB | Faster, smaller |
| Older devices | `simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk` | ~3 MB | Older ARM chips |
### Installation Steps
1. Download the appropriate APK from the assets below
2. Enable "Install from unknown sources" in Android settings
3. Open the downloaded APK file
4. Follow the installation prompts
5. Configure WebDAV settings in the app
---
## ⚙️ Features
- ✅ Automatic WebDAV sync every 30 minutes (~0.4% battery/day)
- ✅ Smart gateway detection (home network auto-detection)
- ✅ Material Design 3 UI
- ✅ Privacy-focused (no tracking, no analytics)
- ✅ Offline-first architecture
---
## 🔄 Updating from Previous Version
Simply install this APK over the existing installation - all data and settings will be preserved.
---
## 📱 Obtanium - Auto-Update App
Get automatic updates with [Obtanium](https://github.com/ImranR98/Obtanium/releases/latest).
**Setup:**
1. Install Obtanium from the link above
2. Add app with this URL: `https://github.com/dettmersLiq/simple-notes-sync`
3. Enable auto-updates
---
## 🆘 Support
For issues or questions, please open an issue on GitHub.
---
## 🔒 Privacy & Security
- All data synced via your own WebDAV server
- No third-party analytics or tracking
- No internet permissions except for WebDAV sync
- All sync operations encrypted (HTTPS)
- Open source - audit the code yourself
---
## 🛠️ Built With
- **Language:** Kotlin
- **UI:** Material Design 3
- **Sync:** WorkManager + WebDAV
- **Target SDK:** Android 16 (API 36)
- **Min SDK:** Android 8.0 (API 26)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored
View File

@@ -15,6 +15,11 @@ android/*.apk
android/*.ap_ android/*.ap_
android/*.aab android/*.aab
# Signing files (NEVER commit these!)
android/key.properties
android/app/*.jks
android/app/*.keystore
# Gradle # Gradle
.gradle .gradle
build/ build/

472
DOCS.md Normal file
View File

@@ -0,0 +1,472 @@
# Simple Notes Sync - Technische Dokumentation
Diese Datei enthält detaillierte technische Informationen über die Implementierung, Architektur und erweiterte Funktionen.
---
## 📐 Architektur
### Gesamtübersicht
```
┌─────────────────┐
│ Android App │
│ (Kotlin) │
└────────┬────────┘
│ WebDAV/HTTP
┌────────▼────────┐
│ WebDAV Server │
│ (Docker) │
└─────────────────┘
```
### Android App Architektur
```
app/
├── models/
│ ├── Note.kt # Data class für Notizen
│ └── SyncStatus.kt # Sync-Status Enum
├── storage/
│ └── NotesStorage.kt # Lokale JSON-Datei Speicherung
├── sync/
│ ├── WebDavSyncService.kt # WebDAV Sync-Logik
│ ├── NetworkMonitor.kt # WLAN-Erkennung
│ ├── SyncWorker.kt # WorkManager Background Worker
│ └── BootReceiver.kt # Device Reboot Handler
├── adapters/
│ └── NotesAdapter.kt # RecyclerView Adapter
├── utils/
│ ├── Constants.kt # App-Konstanten
│ ├── NotificationHelper.kt# Notification Management
│ └── Logger.kt # Debug/Release Logging
└── activities/
├── MainActivity.kt # Hauptansicht mit Liste
├── NoteEditorActivity.kt# Editor für Notizen
└── SettingsActivity.kt # Server-Konfiguration
```
---
## 🔄 Auto-Sync Implementierung
### WorkManager Periodic Task
Der Auto-Sync basiert auf **WorkManager** mit folgender Konfiguration:
```kotlin
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Nur WiFi
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
30, TimeUnit.MINUTES, // Alle 30 Minuten
10, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.build()
```
**Warum WorkManager?**
- ✅ Läuft auch wenn App geschlossen ist
- ✅ Automatischer Restart nach Device Reboot
- ✅ Battery-efficient (Android managed)
- ✅ Garantierte Ausführung bei erfüllten Constraints
### Network Detection
Statt SSID-basierter Erkennung (Android 13+ Privacy-Probleme) verwenden wir **Gateway IP Comparison**:
```kotlin
fun isInHomeNetwork(): Boolean {
val gatewayIP = getGatewayIP() // z.B. 192.168.0.1
val serverIP = extractIPFromUrl(serverUrl) // z.B. 192.168.0.188
return isSameNetwork(gatewayIP, serverIP) // Prüft /24 Netzwerk
}
```
**Vorteile:**
- ✅ Keine Location Permissions nötig
- ✅ Funktioniert mit allen Android Versionen
- ✅ Zuverlässig und schnell
### Sync Flow
```
1. WorkManager wacht auf (alle 30 Min)
2. Check: WiFi connected?
3. Check: Same network as server?
4. Load local notes
5. Upload neue/geänderte Notes → Server
6. Download remote notes ← Server
7. Merge & resolve conflicts
8. Update local storage
9. Show notification (if changes)
```
---
## 🔋 Akku-Optimierung
### Verbrauchsanalyse
| Komponente | Frequenz | Verbrauch | Details |
|------------|----------|-----------|---------|
| WorkManager Wakeup | Alle 30 Min | ~0.15 mAh | System wacht auf |
| Network Check | 48x/Tag | ~0.03 mAh | Gateway IP check |
| WebDAV Sync | 2-3x/Tag | ~1.5 mAh | Nur bei Änderungen |
| **Total** | - | **~12 mAh/Tag** | **~0.4%** bei 3000mAh |
### Optimierungen
1. **IP Caching**
```kotlin
private var cachedServerIP: String? = null
// DNS lookup nur 1x beim Start, nicht bei jedem Check
```
2. **Throttling**
```kotlin
private var lastSyncTime = 0L
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 Sync/Min
```
3. **Conditional Logging**
```kotlin
object Logger {
fun d(tag: String, msg: String) {
if (BuildConfig.DEBUG) Log.d(tag, msg)
}
}
```
4. **Network Constraints**
- Nur WiFi (nicht mobile Daten)
- Nur wenn Server erreichbar
- Keine permanenten Listeners
---
## 📦 WebDAV Sync Details
### Upload Flow
```kotlin
suspend fun uploadNotes(): Int {
val localNotes = storage.loadAllNotes()
var uploadedCount = 0
for (note in localNotes) {
if (note.syncStatus == SyncStatus.PENDING) {
val jsonContent = note.toJson()
val remotePath = "$serverUrl/${note.id}.json"
sardine.put(remotePath, jsonContent.toByteArray())
note.syncStatus = SyncStatus.SYNCED
storage.saveNote(note)
uploadedCount++
}
}
return uploadedCount
}
```
### Download Flow
```kotlin
suspend fun downloadNotes(): DownloadResult {
val remoteFiles = sardine.list(serverUrl)
var downloadedCount = 0
var conflictCount = 0
for (file in remoteFiles) {
if (!file.name.endsWith(".json")) continue
val content = sardine.get(file.href)
val remoteNote = Note.fromJson(content)
val localNote = storage.loadNote(remoteNote.id)
if (localNote == null) {
// Neue Note vom Server
storage.saveNote(remoteNote)
downloadedCount++
} else if (localNote.modifiedAt < remoteNote.modifiedAt) {
// Server hat neuere Version
storage.saveNote(remoteNote)
downloadedCount++
} else if (localNote.modifiedAt > remoteNote.modifiedAt) {
// Lokale Version ist neuer → Conflict
resolveConflict(localNote, remoteNote)
conflictCount++
}
}
return DownloadResult(downloadedCount, conflictCount)
}
```
### Conflict Resolution
Strategie: **Last-Write-Wins** mit **Conflict Copy**
```kotlin
fun resolveConflict(local: Note, remote: Note) {
// Remote Note umbenennen (Conflict Copy)
val conflictNote = remote.copy(
id = "${remote.id}_conflict_${System.currentTimeMillis()}",
title = "${remote.title} (Konflikt)"
)
storage.saveNote(conflictNote)
// Lokale Note bleibt
local.syncStatus = SyncStatus.SYNCED
storage.saveNote(local)
}
```
---
## 🔔 Notifications
### Notification Channels
```kotlin
val channel = NotificationChannel(
"notes_sync_channel",
"Notizen Synchronisierung",
NotificationManager.IMPORTANCE_DEFAULT
)
```
### Success Notification
```kotlin
fun showSyncSuccess(context: Context, count: Int) {
val intent = Intent(context, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAGS)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle("Sync erfolgreich")
.setContentText("$count Notizen synchronisiert")
.setContentIntent(pendingIntent) // Click öffnet App
.setAutoCancel(true) // Dismiss on click
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
```
---
## 🛡️ Permissions
Die App benötigt **minimale Permissions**:
```xml
<!-- Netzwerk -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Boot Receiver -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Battery Optimization (optional) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
```
**Keine Location Permissions!**
Frühere Versionen benötigten `ACCESS_FINE_LOCATION` für SSID-Erkennung. Jetzt verwenden wir Gateway IP Comparison.
---
## 🧪 Testing
### Server testen
```bash
# WebDAV Server erreichbar?
curl -u noteuser:password http://192.168.0.188:8080/
# Datei hochladen
echo '{"test":"data"}' > test.json
curl -u noteuser:password -T test.json http://192.168.0.188:8080/test.json
# Datei herunterladen
curl -u noteuser:password http://192.168.0.188:8080/test.json
```
### Android App testen
**Unit Tests:**
```bash
cd android
./gradlew test
```
**Instrumented Tests:**
```bash
./gradlew connectedAndroidTest
```
**Manual Testing Checklist:**
- [ ] Notiz erstellen → in Liste sichtbar
- [ ] Notiz bearbeiten → Änderungen gespeichert
- [ ] Notiz löschen → aus Liste entfernt
- [ ] Manueller Sync → Server Status "Erreichbar"
- [ ] Auto-Sync → Notification nach ~30 Min
- [ ] App schließen → Auto-Sync funktioniert weiter
- [ ] Device Reboot → Auto-Sync startet automatisch
- [ ] Server offline → Error Notification
- [ ] Notification Click → App öffnet sich
---
## 🚀 Build & Deployment
### Debug Build
```bash
cd android
./gradlew assembleDebug
# APK: app/build/outputs/apk/debug/app-debug.apk
```
### Release Build
```bash
./gradlew assembleRelease
# APK: app/build/outputs/apk/release/app-release-unsigned.apk
```
### Signieren (für Distribution)
```bash
# Keystore erstellen
keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias
# APK signieren
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore my-release-key.jks \
app-release-unsigned.apk my-alias
# Optimieren
zipalign -v 4 app-release-unsigned.apk app-release.apk
```
---
## 🐛 Debugging
### LogCat Filter
```bash
# Nur App-Logs
adb logcat -s SimpleNotesApp NetworkMonitor SyncWorker WebDavSyncService
# Mit Timestamps
adb logcat -v time -s SyncWorker
# In Datei speichern
adb logcat -s SyncWorker > sync_debug.log
```
### Common Issues
**Problem: Auto-Sync funktioniert nicht**
```
Lösung: Akku-Optimierung deaktivieren
Settings → Apps → Simple Notes → Battery → Don't optimize
```
**Problem: Server nicht erreichbar**
```
Check:
1. Server läuft? → docker-compose ps
2. IP korrekt? → ip addr show
3. Port offen? → telnet 192.168.0.188 8080
4. Firewall? → sudo ufw allow 8080
```
**Problem: Notifications kommen nicht**
```
Check:
1. Notification Permission erteilt?
2. Do Not Disturb aktiv?
3. App im Background? → Force stop & restart
```
---
## 📚 Dependencies
```gradle
// Core
androidx.core:core-ktx:1.12.0
androidx.appcompat:appcompat:1.6.1
com.google.android.material:material:1.11.0
// Lifecycle
androidx.lifecycle:lifecycle-runtime-ktx:2.7.0
// RecyclerView
androidx.recyclerview:recyclerview:1.3.2
// Coroutines
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
// WorkManager
androidx.work:work-runtime-ktx:2.9.0
// WebDAV Client
com.github.thegrizzlylabs:sardine-android:0.8
// Broadcast (deprecated but working)
androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
```
---
## 🔮 Roadmap
### v1.1
- [ ] Suche & Filter
- [ ] Dark Mode
- [ ] Tags/Kategorien
- [ ] Markdown Preview
### v2.0
- [ ] Desktop Client (Flutter)
- [ ] End-to-End Verschlüsselung
- [ ] Shared Notes (Collaboration)
- [ ] Attachment Support
---
## 📖 Weitere Dokumentation
- [Project Docs](https://github.com/inventory69/project-docs/tree/main/simple-notes-sync)
- [Android Guide](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md)
- [Bugfix Documentation](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/BUGFIX_SYNC_SPAM_AND_NOTIFICATIONS.md)
---
**Letzte Aktualisierung:** 21. Dezember 2025

188
GITHUB_ACTIONS_SETUP.md Normal file
View File

@@ -0,0 +1,188 @@
# GitHub Actions Setup Guide
This guide explains how to set up the GitHub Actions workflow for automated APK builds with proper signing.
## Overview
The workflow in `.github/workflows/build-production-apk.yml` automatically:
- Builds signed APKs on every push to `main`
- Generates version numbers using `YYYY.MM.DD` + build number
- Creates 3 APK variants (universal, arm64-v8a, armeabi-v7a)
- Creates GitHub releases with all APKs attached
## Prerequisites
- GitHub CLI (`gh`) installed
- Java 17+ installed (for keytool)
- Git repository initialized with GitHub remote
## Step 1: Generate Signing Keystore
⚠️ **IMPORTANT**: Store the keystore securely! Without it, you cannot publish updates to your app.
```bash
# Navigate to project root
cd /path/to/simple-notes-sync
# Generate keystore (replace values as needed)
keytool -genkey -v \
-keystore android/app/simple-notes-release.jks \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-alias simple-notes
# You will be prompted for:
# - Keystore password (remember this!)
# - Key password (remember this!)
# - Your name, organization, etc.
```
**Store these securely:**
- Keystore password
- Key password
- Alias: `simple-notes`
- Keystore file: `android/app/simple-notes-release.jks`
⚠️ **BACKUP**: Make a backup of the keystore file in a secure location (NOT in the repository).
## Step 2: Base64 Encode Keystore
```bash
# Create base64 encoded version
base64 android/app/simple-notes-release.jks > simple-notes-release.jks.b64
# Or on macOS:
base64 -i android/app/simple-notes-release.jks -o simple-notes-release.jks.b64
```
## Step 3: Set GitHub Secrets
Using GitHub CLI (recommended):
```bash
# Set KEYSTORE_BASE64 secret
gh secret set KEYSTORE_BASE64 < simple-notes-release.jks.b64
# Set KEYSTORE_PASSWORD (will prompt for input)
gh secret set KEYSTORE_PASSWORD
# Set KEY_PASSWORD (will prompt for input)
gh secret set KEY_PASSWORD
# Set KEY_ALIAS (value: simple-notes)
printf "simple-notes" | gh secret set KEY_ALIAS
```
Or manually via GitHub web interface:
1. Go to repository Settings → Secrets and variables → Actions
2. Click "New repository secret"
3. Add these secrets:
- `KEYSTORE_BASE64`: Paste content of `simple-notes-release.jks.b64`
- `KEYSTORE_PASSWORD`: Your keystore password
- `KEY_PASSWORD`: Your key password
- `KEY_ALIAS`: `simple-notes`
## Step 4: Verify Setup
```bash
# Check secrets are set
gh secret list
# Expected output:
# KEYSTORE_BASE64 Updated YYYY-MM-DD
# KEYSTORE_PASSWORD Updated YYYY-MM-DD
# KEY_PASSWORD Updated YYYY-MM-DD
# KEY_ALIAS Updated YYYY-MM-DD
```
## Step 5: Cleanup
```bash
# Remove sensitive files (they're in .gitignore, but double-check)
rm simple-notes-release.jks.b64
rm -f android/key.properties # Generated by workflow
# Verify keystore is NOT tracked by git
git status | grep -i jks
# Should return nothing
```
## Step 6: Trigger First Build
```bash
# Commit and push to main
git add .
git commit -m "🚀 feat: Add GitHub Actions deployment workflow"
git push origin main
# Or manually trigger workflow
gh workflow run build-production-apk.yml
```
## Verification
1. Go to GitHub repository → Actions tab
2. Check workflow run status
3. Once complete, go to Releases tab
4. Verify release was created with 3 APK variants
5. Download and test one of the APKs
## Troubleshooting
### Build fails with "Keystore not found"
- Check `KEYSTORE_BASE64` secret is set correctly
- Verify base64 encoding was done without line breaks
### Build fails with "Incorrect password"
- Verify `KEYSTORE_PASSWORD` and `KEY_PASSWORD` are correct
- Re-set secrets if needed
### APK files not found
- Check build logs for errors in assembleRelease step
- Verify APK output paths match workflow expectations
### Updates don't work
- Ensure you're using the same keystore for all builds
- Verify `applicationId` in build.gradle.kts matches
## Security Notes
- ✅ Keystore is base64-encoded in GitHub secrets (secure)
- ✅ Passwords are stored in GitHub secrets (encrypted)
-`key.properties` and `.jks` files are in `.gitignore`
- ⚠️ Never commit keystore files to repository
- ⚠️ Keep backup of keystore in secure location
- ⚠️ Don't share keystore passwords
## Versioning
Versions follow this pattern:
- **Version Name**: `YYYY.MM.DD` (e.g., `2025.01.15`)
- **Version Code**: GitHub run number (e.g., `42`)
- **Release Tag**: `vYYYY.MM.DD-prod.BUILD` (e.g., `v2025.01.15-prod.42`)
This ensures:
- Semantic versioning based on release date
- Incremental version codes for Play Store compatibility
- Clear distinction between builds
## APK Variants
The workflow generates 3 APK variants:
1. **Universal APK** (~5 MB)
- Works on all devices
- Larger file size
- Recommended for most users
2. **arm64-v8a APK** (~3 MB)
- For modern devices (2018+)
- Smaller, faster
- 64-bit ARM processors
3. **armeabi-v7a APK** (~3 MB)
- For older devices
- 32-bit ARM processors
Users can choose based on their device - Obtanium auto-updates work with all variants.

350
README.md
View File

@@ -1,253 +1,153 @@
# Simple Notes Sync # Simple Notes Sync 📝
Minimalistische Offline-Notiz-App mit automatischer WLAN-Synchronisierung. > Minimalistische Android-App für Offline-Notizen mit automatischer WLAN-Synchronisierung
## 📱 Features Eine schlanke Notiz-App ohne Schnickschnack - perfekt für schnelle Gedanken, die automatisch zu Hause synchronisiert werden.
- ✅ Offline-first: Notizen lokal erstellen und bearbeiten ---
- ✅ Auto-Sync: Automatische Synchronisierung im Heim-WLAN
- ✅ WebDAV: Docker-basierter Server
- ✅ Simpel: Fokus auf Funktionalität
- ✅ Robust: Fehlerbehandlung und Konfliktauflösung
## 🏗️ Projekt-Struktur ## ✨ Features
``` - 📝 **Offline-first** - Notizen werden lokal gespeichert und sind immer verfügbar
simple-notes-sync/ - 🔄 **Auto-Sync** - Automatische Synchronisierung wenn du im Heimnetzwerk bist
├── server/ # Docker WebDAV Server - 🏠 **WebDAV Server** - Deine Daten bleiben bei dir (Docker-Container)
│ ├── docker-compose.yml - 🔋 **Akkuschonend** - Nur ~0.4% Akkuverbrauch pro Tag
│ ├── .env.example - 🚫 **Keine Cloud** - Keine Google, keine Microsoft, keine Drittanbieter
│ └── README.md - 🔐 **Privacy** - Keine Tracking, keine Analytics, keine Standort-Berechtigungen
└── android/ # Android App (Kotlin)
└── (Android Studio Projekt)
```
## 🚀 Quick Start ---
### 1. Server starten ## 📥 Installation
```bash ### Android App
cd server
cp .env.example .env
nano .env # Passwort anpassen
docker-compose up -d
```
### 2. Server testen **Option 1: APK herunterladen**
```bash 1. Neueste [Release](../../releases/latest) öffnen
curl -u noteuser:your_password http://localhost:8080/ 2. `app-debug.apk` herunterladen
``` 3. APK auf dem Handy installieren
### 3. Android App entwickeln **Option 2: Selbst bauen**
```bash
cd android
# In Android Studio öffnen
# Build & Run
```
## 📖 Dokumentation
### In diesem Repository:
- **[QUICKSTART.md](QUICKSTART.md)** - Schnellstart-Anleitung
- **[server/README.md](server/README.md)** - Server-Verwaltung
### Vollständige Dokumentation (project-docs):
- [README.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/README.md) - Projekt-Übersicht & Architektur
- [IMPLEMENTATION_PLAN.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/IMPLEMENTATION_PLAN.md) - Detaillierter Sprint-Plan
- [SERVER_SETUP.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SERVER_SETUP.md) - Server-Setup Details
- [ANDROID_GUIDE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md) - 📱 Kompletter Android-Code
- [NOTIFICATIONS.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/NOTIFICATIONS.md) - Notification-System Details
- [WINDOWS_SETUP.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/WINDOWS_SETUP.md) - 🪟 Windows + Android Studio Setup
- [CODE_REFERENCE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/CODE_REFERENCE.md) - Schnelle Code-Referenz
## ⚙️ Server Konfiguration
**Standard-Credentials:**
- Username: `noteuser`
- Password: Siehe `.env` im `server/` Verzeichnis
**Server-URL:**
- Lokal: `http://localhost:8080/`
- Im Netzwerk: `http://YOUR_IP:8080/`
IP-Adresse finden:
```bash
ip addr show | grep "inet " | grep -v 127.0.0.1
```
## 📱 Android App Setup
### Vorraussetzungen
- Android Studio Hedgehog (2023.1.1) oder neuer
- JDK 17
- Min SDK 24 (Android 7.0)
- Target SDK 34 (Android 14)
### In App konfigurieren
1. App starten
2. Einstellungen öffnen
3. Server-URL eintragen (z.B. `http://192.168.1.100:8080/`)
4. Username & Passwort eingeben
5. Heim-WLAN SSID eingeben
6. "Verbindung testen"
## 🔧 Entwicklung
### Server-Management
```bash
# Status prüfen
docker-compose ps
# Logs anschauen
docker-compose logs -f
# Neustarten
docker-compose restart
# Stoppen
docker-compose down
```
### Android-Build
```bash ```bash
cd android cd android
./gradlew assembleDebug ./gradlew assembleDebug
# APK: android/app/build/outputs/apk/debug/app-debug.apk
# APK Location:
# app/build/outputs/apk/debug/app-debug.apk
``` ```
## 🧪 Testing ### WebDAV Server
### Server-Test Der Server läuft als Docker-Container und speichert deine Notizen.
```bash ```bash
# Testdatei hochladen cd server
echo '{"id":"test","title":"Test","content":"Hello"}' > test.json cp .env.example .env
curl -u noteuser:password -T test.json http://localhost:8080/test.json nano .env # Passwort anpassen!
# Datei abrufen
curl -u noteuser:password http://localhost:8080/test.json
# Datei löschen
curl -u noteuser:password -X DELETE http://localhost:8080/test.json
```
### Android-App
1. Notiz erstellen → speichern → in Liste sichtbar ✓
2. WLAN verbinden → Auto-Sync ✓
3. Server offline → Fehlermeldung ✓
4. Konflikt-Szenario → Auflösung ✓
## 📦 Deployment
### Server (Production)
**Option 1: Lokaler Server (Raspberry Pi, etc.)**
```bash
docker-compose up -d docker-compose up -d
``` ```
**Option 2: VPS (DigitalOcean, Hetzner, etc.)** **Server testen:**
```bash ```bash
# Mit HTTPS (empfohlen) curl -u noteuser:dein_passwort http://192.168.0.XXX:8080/
# Zusätzlich: Reverse Proxy (nginx/Caddy) + Let's Encrypt
``` ```
### Android App
```bash
# Release Build
./gradlew assembleRelease
# APK signieren
# Play Store Upload oder Direct Install
```
## 🔐 Security
**Entwicklung:**
- ✅ HTTP Basic Auth
- ✅ Nur im lokalen Netzwerk
**Produktion:**
- ⚠️ HTTPS mit SSL/TLS (empfohlen)
- ⚠️ Starkes Passwort
- ⚠️ Firewall-Regeln
- ⚠️ VPN für externen Zugriff
## 🐛 Troubleshooting
### Server startet nicht
```bash
# Port bereits belegt?
sudo netstat -tlnp | grep 8080
# Logs checken
docker-compose logs webdav
```
### Android kann nicht verbinden
- Ist Android im gleichen WLAN?
- Ist die Server-IP korrekt?
- Firewall blockiert Port 8080?
- Credentials korrekt?
```bash
# Ping zum Server
ping YOUR_SERVER_IP
# Port erreichbar?
telnet YOUR_SERVER_IP 8080
```
## 📝 TODO / Roadmap
### Version 1.0 (MVP)
- [x] Docker WebDAV Server
- [ ] Android Basic CRUD
- [ ] Auto-Sync bei WLAN
- [ ] Error Handling
- [ ] Notifications
### Version 1.1
- [ ] Suche
- [ ] Dark Mode
- [ ] Markdown-Support
### Version 2.0
- [ ] Desktop-Client (Flutter Desktop)
- [ ] Tags/Kategorien
- [ ] Verschlüsselung
- [ ] Shared Notes
## 📄 License
MIT License - siehe [LICENSE](LICENSE)
## 👤 Author
Created for personal use - 2025
## 🙏 Acknowledgments
- [bytemark/webdav](https://hub.docker.com/r/bytemark/webdav) - Docker WebDAV Server
- [Sardine Android](https://github.com/thegrizzlylabs/sardine-android) - WebDAV Client
- [Android WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager) - Background Tasks
--- ---
**Project Start:** 19. Dezember 2025 ## 🚀 Schnellstart
**Status:** 🚧 In Development
1. **Server starten** (siehe oben)
2. **App installieren** und öffnen
3. **Einstellungen öffnen** (⚙️ Symbol oben rechts)
4. **Server konfigurieren:**
- Server-URL: `http://192.168.0.XXX:8080/notes`
- Benutzername: `noteuser`
- Passwort: (aus `.env` Datei)
- Auto-Sync: **AN**
5. **Fertig!** Notizen werden jetzt automatisch synchronisiert
---
## 💡 Wie funktioniert Auto-Sync?
Die App prüft **alle 30 Minuten**, ob:
- ✅ WLAN verbunden ist
- ✅ Server im gleichen Netzwerk erreichbar ist
- ✅ Neue Notizen vorhanden sind
Wenn alle Bedingungen erfüllt → **Automatische Synchronisierung**
**Wichtig:** Funktioniert nur im selben Netzwerk wie der Server (kein Internet-Zugriff nötig!)
---
## 🔋 Akkuverbrauch
| Komponente | Verbrauch/Tag |
|------------|---------------|
| WorkManager (alle 30 Min) | ~0.3% |
| Netzwerk-Checks | ~0.1% |
| **Total** | **~0.4%** |
Bei einem 3000 mAh Akku entspricht das ~12 mAh pro Tag.
---
## 📱 Screenshots
_TODO: Screenshots hinzufügen_
---
## 🛠️ Technische Details
Mehr Infos zur Architektur und Implementierung findest du in der [technischen Dokumentation](DOCS.md).
**Stack:**
- **Android:** Kotlin, Material Design 3, WorkManager
- **Server:** Docker, WebDAV (bytemark/webdav)
- **Sync:** Sardine Android (WebDAV Client)
---
## 🐛 Troubleshooting
### Server nicht erreichbar
```bash
# Server Status prüfen
docker-compose ps
# Logs ansehen
docker-compose logs -f
# IP-Adresse finden
ip addr show | grep "inet " | grep -v 127.0.0.1
```
### Auto-Sync funktioniert nicht
1. **Akku-Optimierung deaktivieren**
- Einstellungen → Apps → Simple Notes → Akku → Nicht optimieren
2. **WLAN Verbindung prüfen**
- App funktioniert nur im selben Netzwerk wie der Server
3. **Server-Status in App prüfen**
- Settings → Server-Status sollte "Erreichbar" zeigen
Mehr Details in der [Dokumentation](DOCS.md).
---
## 🤝 Beitragen
Contributions sind willkommen! Bitte öffne ein Issue oder Pull Request.
---
## 📄 Lizenz
MIT License - siehe [LICENSE](LICENSE)
---
**Projekt Start:** 19. Dezember 2025
**Status:** ✅ Funktional & Produktiv

335
README.old.md Normal file
View File

@@ -0,0 +1,335 @@
# Simple Notes Sync 📝
> Minimalistische Android-App für Offline-Notizen mit automatischer WLAN-Synchronisierung
Eine schlanke Notiz-App ohne Schnickschnack - perfekt für schnelle Gedanken, die automatisch zu Hause synchronisiert werden.
---
## ✨ Features
- 📝 **Offline-first** - Notizen werden lokal gespeichert und sind immer verfügbar
- 🔄 **Auto-Sync** - Automatische Synchronisierung wenn du im Heimnetzwerk bist
- 🏠 **WebDAV Server** - Deine Daten bleiben bei dir (Docker-Container)
- 🔋 **Akkuschonend** - Nur ~0.4% Akkuverbrauch pro Tag
- 🚫 **Keine Cloud** - Keine Google, keine Microsoft, keine Drittanbieter
- 🔐 **Privacy** - Keine Tracking, keine Analytics, keine Standort-Berechtigungen
---
## 📥 Installation
### Android App
**Option 1: APK herunterladen**
1. Neueste [Release](../../releases/latest) öffnen
2. `app-debug.apk` herunterladen
3. APK auf dem Handy installieren
**Option 2: Selbst bauen**
```bash
cd android
./gradlew assembleDebug
# APK: android/app/build/outputs/apk/debug/app-debug.apk
```
### WebDAV Server
Der Server läuft als Docker-Container und speichert deine Notizen.
```bash
cd server
cp .env.example .env
nano .env # Passwort anpassen!
docker-compose up -d
```
**Server testen:**
```bash
curl -u noteuser:dein_passwort http://192.168.0.XXX:8080/
```
---
## 🚀 Schnellstart
1. **Server starten** (siehe oben)
2. **App installieren** und öffnen
3. **Einstellungen öffnen** (⚙️ Symbol oben rechts)
4. **Server konfigurieren:**
- Server-URL: `http://192.168.0.XXX:8080/notes`
- Benutzername: `noteuser`
- Passwort: (aus `.env` Datei)
- Auto-Sync: **AN**
5. **Fertig!** Notizen werden jetzt automatisch synchronisiert
---
## 💡 Wie funktioniert Auto-Sync?
Die App prüft **alle 30 Minuten**, ob:
- ✅ WLAN verbunden ist
- ✅ Server im gleichen Netzwerk erreichbar ist
- ✅ Neue Notizen vorhanden sind
Wenn alle Bedingungen erfüllt → **Automatische Synchronisierung**
**Wichtig:** Funktioniert nur im selben Netzwerk wie der Server (kein Internet-Zugriff nötig!)
---
## 🔋 Akkuverbrauch
| Komponente | Verbrauch/Tag |
|------------|---------------|
| WorkManager (alle 30 Min) | ~0.3% |
| Netzwerk-Checks | ~0.1% |
| **Total** | **~0.4%** |
Bei einem 3000 mAh Akku entspricht das ~12 mAh pro Tag.
---
## 📱 Screenshots
_TODO: Screenshots hinzufügen_
---
## 🛠️ Technische Details
Mehr Infos zur Architektur und Implementierung findest du in der [technischen Dokumentation](DOCS.md).
**Stack:**
- **Android:** Kotlin, Material Design 3, WorkManager
- **Server:** Docker, WebDAV (bytemark/webdav)
- **Sync:** Sardine Android (WebDAV Client)
---
## 📄 Lizenz
[Lizenz hier einfügen]
---
## 🤝 Beitragen
Contributions sind willkommen! Bitte öffne ein Issue oder Pull Request.
---
## 📄 Lizenz
MIT License - siehe [LICENSE](LICENSE)
---
**Projekt Start:** 19. Dezember 2025
**Status:** ✅ Funktional & Produktiv
## 📖 Dokumentation
### In diesem Repository:
- **[QUICKSTART.md](QUICKSTART.md)** - Schnellstart-Anleitung
- **[server/README.md](server/README.md)** - Server-Verwaltung
### Vollständige Dokumentation (project-docs):
- [README.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/README.md) - Projekt-Übersicht & Architektur
- [IMPLEMENTATION_PLAN.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/IMPLEMENTATION_PLAN.md) - Detaillierter Sprint-Plan
- [SERVER_SETUP.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SERVER_SETUP.md) - Server-Setup Details
- [ANDROID_GUIDE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md) - 📱 Kompletter Android-Code
- [NOTIFICATIONS.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/NOTIFICATIONS.md) - Notification-System Details
- [WINDOWS_SETUP.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/WINDOWS_SETUP.md) - 🪟 Windows + Android Studio Setup
- [CODE_REFERENCE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/CODE_REFERENCE.md) - Schnelle Code-Referenz
## ⚙️ Server Konfiguration
**Standard-Credentials:**
- Username: `noteuser`
- Password: Siehe `.env` im `server/` Verzeichnis
**Server-URL:**
- Lokal: `http://localhost:8080/`
- Im Netzwerk: `http://YOUR_IP:8080/`
IP-Adresse finden:
```bash
ip addr show | grep "inet " | grep -v 127.0.0.1
```
## 📱 Android App Setup
### Vorraussetzungen
- Android Studio Hedgehog (2023.1.1) oder neuer
- JDK 17
- Min SDK 24 (Android 7.0)
- Target SDK 34 (Android 14)
### In App konfigurieren
1. App starten
2. Einstellungen öffnen
3. Server-URL eintragen (z.B. `http://192.168.1.100:8080/`)
4. Username & Passwort eingeben
5. Heim-WLAN SSID eingeben
6. "Verbindung testen"
## 🔧 Entwicklung
### Server-Management
```bash
# Status prüfen
docker-compose ps
# Logs anschauen
docker-compose logs -f
# Neustarten
docker-compose restart
# Stoppen
docker-compose down
```
### Android-Build
```bash
cd android
./gradlew assembleDebug
# APK Location:
# app/build/outputs/apk/debug/app-debug.apk
```
## 🧪 Testing
### Server-Test
```bash
# Testdatei hochladen
echo '{"id":"test","title":"Test","content":"Hello"}' > test.json
curl -u noteuser:password -T test.json http://localhost:8080/test.json
# Datei abrufen
curl -u noteuser:password http://localhost:8080/test.json
# Datei löschen
curl -u noteuser:password -X DELETE http://localhost:8080/test.json
```
### Android-App
1. Notiz erstellen → speichern → in Liste sichtbar ✓
2. WLAN verbinden → Auto-Sync ✓
3. Server offline → Fehlermeldung ✓
4. Konflikt-Szenario → Auflösung ✓
## 📦 Deployment
### Server (Production)
**Option 1: Lokaler Server (Raspberry Pi, etc.)**
```bash
docker-compose up -d
```
**Option 2: VPS (DigitalOcean, Hetzner, etc.)**
```bash
# Mit HTTPS (empfohlen)
# Zusätzlich: Reverse Proxy (nginx/Caddy) + Let's Encrypt
```
### Android App
```bash
# Release Build
./gradlew assembleRelease
# APK signieren
# Play Store Upload oder Direct Install
```
## 🔐 Security
**Entwicklung:**
- ✅ HTTP Basic Auth
- ✅ Nur im lokalen Netzwerk
**Produktion:**
- ⚠️ HTTPS mit SSL/TLS (empfohlen)
- ⚠️ Starkes Passwort
- ⚠️ Firewall-Regeln
- ⚠️ VPN für externen Zugriff
## 🐛 Troubleshooting
### Server startet nicht
```bash
# Port bereits belegt?
sudo netstat -tlnp | grep 8080
# Logs checken
docker-compose logs webdav
```
### Android kann nicht verbinden
- Ist Android im gleichen WLAN?
- Ist die Server-IP korrekt?
- Firewall blockiert Port 8080?
- Credentials korrekt?
```bash
# Ping zum Server
ping YOUR_SERVER_IP
# Port erreichbar?
telnet YOUR_SERVER_IP 8080
```
## 📝 TODO / Roadmap
### Version 1.0 (MVP)
- [x] Docker WebDAV Server
- [ ] Android Basic CRUD
- [ ] Auto-Sync bei WLAN
- [ ] Error Handling
- [ ] Notifications
### Version 1.1
- [ ] Suche
- [ ] Dark Mode
- [ ] Markdown-Support
### Version 2.0
- [ ] Desktop-Client (Flutter Desktop)
- [ ] Tags/Kategorien
- [ ] Verschlüsselung
- [ ] Shared Notes
## 📄 License
MIT License - siehe [LICENSE](LICENSE)
## 👤 Author
Created for personal use - 2025
## 🙏 Acknowledgments
- [bytemark/webdav](https://hub.docker.com/r/bytemark/webdav) - Docker WebDAV Server
- [Sardine Android](https://github.com/thegrizzlylabs/sardine-android) - WebDAV Client
- [Android WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager) - Background Tasks
---
**Project Start:** 19. Dezember 2025
**Status:** 🚧 In Development

View File

@@ -17,20 +17,55 @@ android {
versionName = "1.0" versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Enable multiple APKs per ABI for smaller downloads
splits {
abi {
isEnable = true
reset()
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true // Also generate universal APK
}
}
}
// Signing configuration for release builds
signingConfigs {
create("release") {
// Load keystore configuration from key.properties file
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
val keystoreProperties = java.util.Properties()
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
// Use release signing config if available, otherwise debug
signingConfig = if (rootProject.file("key.properties").exists()) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
} }
} }
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
buildConfig = true // Enable BuildConfig generation
} }
compileOptions { compileOptions {
@@ -59,6 +94,9 @@ dependencies {
implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
// LocalBroadcastManager für UI Refresh
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
// Testing (bleiben so) // Testing (bleiben so)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)

View File

@@ -2,16 +2,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- Network & Sync Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Battery Optimization (for WorkManager background sync) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:name=".SimpleNotesApplication" android:name=".SimpleNotesApplication"
@@ -45,6 +49,17 @@
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:parentActivityName=".MainActivity" /> android:parentActivityName=".MainActivity" />
<!-- Boot Receiver - Startet WorkManager nach Reboot -->
<receiver
android:name=".sync.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>

View File

@@ -1,19 +1,25 @@
package dev.dettmer.simplenotes package dev.dettmer.simplenotes
import android.Manifest import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import dev.dettmer.simplenotes.utils.Logger
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import dev.dettmer.simplenotes.adapters.NotesAdapter import dev.dettmer.simplenotes.adapters.NotesAdapter
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
import android.widget.TextView import android.widget.TextView
@@ -34,9 +40,28 @@ class MainActivity : AppCompatActivity() {
private val storage by lazy { NotesStorage(this) } private val storage by lazy { NotesStorage(this) }
companion object { companion object {
private const val TAG = "MainActivity"
private const val REQUEST_NOTIFICATION_PERMISSION = 1001 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
@@ -56,9 +81,25 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.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() loadNotes()
} }
override fun onPause() {
super.onPause()
// Unregister BroadcastReceiver
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
}
private fun findViews() { private fun findViews() {
recyclerViewNotes = findViewById(R.id.recyclerViewNotes) recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
textViewEmpty = findViewById(R.id.textViewEmpty) textViewEmpty = findViewById(R.id.textViewEmpty)

View File

@@ -1,50 +1,48 @@
package dev.dettmer.simplenotes package dev.dettmer.simplenotes
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.util.Log
import android.view.MenuItem import android.view.MenuItem
import android.widget.Button import android.widget.Button
import android.widget.EditText import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat import androidx.appcompat.widget.SwitchCompat
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
companion object {
private const val TAG = "SettingsActivity"
}
private lateinit var editTextServerUrl: EditText private lateinit var editTextServerUrl: EditText
private lateinit var editTextUsername: EditText private lateinit var editTextUsername: EditText
private lateinit var editTextPassword: EditText private lateinit var editTextPassword: EditText
private lateinit var editTextHomeSSID: EditText
private lateinit var switchAutoSync: SwitchCompat private lateinit var switchAutoSync: SwitchCompat
private lateinit var buttonTestConnection: Button private lateinit var buttonTestConnection: Button
private lateinit var buttonSyncNow: Button private lateinit var buttonSyncNow: Button
private lateinit var buttonDetectSSID: Button private lateinit var textViewServerStatus: TextView
private val prefs by lazy { private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE) 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings) setContentView(R.layout.activity_settings)
@@ -66,19 +64,20 @@ class SettingsActivity : AppCompatActivity() {
editTextServerUrl = findViewById(R.id.editTextServerUrl) editTextServerUrl = findViewById(R.id.editTextServerUrl)
editTextUsername = findViewById(R.id.editTextUsername) editTextUsername = findViewById(R.id.editTextUsername)
editTextPassword = findViewById(R.id.editTextPassword) editTextPassword = findViewById(R.id.editTextPassword)
editTextHomeSSID = findViewById(R.id.editTextHomeSSID)
switchAutoSync = findViewById(R.id.switchAutoSync) switchAutoSync = findViewById(R.id.switchAutoSync)
buttonTestConnection = findViewById(R.id.buttonTestConnection) buttonTestConnection = findViewById(R.id.buttonTestConnection)
buttonSyncNow = findViewById(R.id.buttonSyncNow) buttonSyncNow = findViewById(R.id.buttonSyncNow)
buttonDetectSSID = findViewById(R.id.buttonDetectSSID) textViewServerStatus = findViewById(R.id.textViewServerStatus)
} }
private fun loadSettings() { private fun loadSettings() {
editTextServerUrl.setText(prefs.getString(Constants.KEY_SERVER_URL, "")) editTextServerUrl.setText(prefs.getString(Constants.KEY_SERVER_URL, ""))
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, "")) editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, "")) editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
editTextHomeSSID.setText(prefs.getString(Constants.KEY_HOME_SSID, ""))
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
// Server Status prüfen
checkServerStatus()
} }
private fun setupListeners() { private fun setupListeners() {
@@ -92,13 +91,16 @@ class SettingsActivity : AppCompatActivity() {
syncNow() syncNow()
} }
buttonDetectSSID.setOnClickListener {
detectCurrentSSID()
}
switchAutoSync.setOnCheckedChangeListener { _, isChecked -> switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked) onAutoSyncToggled(isChecked)
} }
// Server Status Check bei Settings-Änderung
editTextServerUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
checkServerStatus()
}
}
} }
private fun saveSettings() { private fun saveSettings() {
@@ -106,7 +108,6 @@ class SettingsActivity : AppCompatActivity() {
putString(Constants.KEY_SERVER_URL, editTextServerUrl.text.toString().trim()) putString(Constants.KEY_SERVER_URL, editTextServerUrl.text.toString().trim())
putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim()) putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim())
putString(Constants.KEY_PASSWORD, editTextPassword.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) putBoolean(Constants.KEY_AUTO_SYNC, switchAutoSync.isChecked)
apply() apply()
} }
@@ -152,34 +153,41 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
private fun detectCurrentSSID() { private fun checkServerStatus() {
// Check if we have location permission (needed for SSID on Android 10+) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (ContextCompat.checkSelfPermission( if (serverUrl.isNullOrEmpty()) {
this, textViewServerStatus.text = "❌ Nicht konfiguriert"
Manifest.permission.ACCESS_FINE_LOCATION textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
) != PackageManager.PERMISSION_GRANTED
) {
// Request permission
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
REQUEST_LOCATION_PERMISSION
)
return return
} }
textViewServerStatus.text = "🔍 Prüfe..."
textViewServerStatus.setTextColor(getColor(android.R.color.darker_gray))
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
}
} }
// Permission granted, get SSID if (isReachable) {
val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager textViewServerStatus.text = "✅ Erreichbar"
val wifiInfo = wifiManager.connectionInfo textViewServerStatus.setTextColor(getColor(android.R.color.holo_green_dark))
val ssid = wifiInfo.ssid.replace("\"", "")
if (ssid.isNotEmpty() && ssid != "<unknown ssid>") {
editTextHomeSSID.setText(ssid)
showToast("SSID erkannt: $ssid")
} else { } else {
showToast("Nicht mit WLAN verbunden") textViewServerStatus.text = "Nicht erreichbar"
textViewServerStatus.setTextColor(getColor(android.R.color.holo_red_dark))
}
} }
} }
@@ -188,12 +196,11 @@ class SettingsActivity : AppCompatActivity() {
if (enabled) { if (enabled) {
showToast("Auto-Sync aktiviert") showToast("Auto-Sync aktiviert")
// Check battery optimization when enabling
checkBatteryOptimization() checkBatteryOptimization()
// Check background location permission (needed for SSID on Android 12+) restartNetworkMonitor()
checkBackgroundLocationPermission()
} else { } else {
showToast("Auto-Sync deaktiviert") showToast("Auto-Sync deaktiviert")
restartNetworkMonitor()
} }
} }
@@ -240,99 +247,16 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
private fun checkBackgroundLocationPermission() { private fun restartNetworkMonitor() {
// Background location permission only needed on Android 10+ try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { val app = application as SimpleNotesApplication
return Log.d(TAG, "🔄 Restarting NetworkMonitor with new settings")
} app.networkMonitor.stopMonitoring()
app.networkMonitor.startMonitoring()
// First check if we have foreground location Log.d(TAG, "✅ NetworkMonitor restarted successfully")
if (ContextCompat.checkSelfPermission( } catch (e: Exception) {
this, Log.e(TAG, "❌ Failed to restart NetworkMonitor", e)
Manifest.permission.ACCESS_FINE_LOCATION showToast("Fehler beim Neustart des NetworkMonitors")
) != 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")
}
}
} }
} }

View File

@@ -1,7 +1,7 @@
package dev.dettmer.simplenotes package dev.dettmer.simplenotes
import android.app.Application import android.app.Application
import android.util.Log import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
@@ -11,31 +11,34 @@ class SimpleNotesApplication : Application() {
private const val TAG = "SimpleNotesApp" private const val TAG = "SimpleNotesApp"
} }
private lateinit var networkMonitor: NetworkMonitor lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.d(TAG, "🚀 Application onCreate()") Logger.d(TAG, "🚀 Application onCreate()")
// Initialize notification channel // Initialize notification channel
NotificationHelper.createNotificationChannel(this) NotificationHelper.createNotificationChannel(this)
Log.d(TAG, "✅ Notification channel created") Logger.d(TAG, "✅ Notification channel created")
// Initialize and start NetworkMonitor at application level // Initialize NetworkMonitor (WorkManager-based)
// CRITICAL: Use applicationContext, not 'this'! // VORTEIL: WorkManager läuft auch ohne aktive App!
networkMonitor = NetworkMonitor(applicationContext) networkMonitor = NetworkMonitor(applicationContext)
// Start WorkManager periodic sync
// Dies läuft im Hintergrund auch wenn App geschlossen ist
networkMonitor.startMonitoring() networkMonitor.startMonitoring()
Log.d(TAG, "NetworkMonitor initialized and started") Logger.d(TAG, "WorkManager-based auto-sync initialized")
} }
override fun onTerminate() { override fun onTerminate() {
super.onTerminate() super.onTerminate()
Log.d(TAG, "🛑 Application onTerminate()") Logger.d(TAG, "🛑 Application onTerminate()")
// Clean up NetworkMonitor when app is terminated // WorkManager läuft weiter auch nach onTerminate!
networkMonitor.stopMonitoring() // 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 package dev.dettmer.simplenotes.sync
import android.content.Context 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.net.wifi.WifiManager
import android.os.Build import androidx.work.*
import android.util.Log
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import java.util.concurrent.TimeUnit 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) { class NetworkMonitor(private val context: Context) {
companion object { companion object {
private const val TAG = "NetworkMonitor" private const val TAG = "NetworkMonitor"
private const val AUTO_SYNC_WORK_NAME = "auto_sync_periodic"
} }
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) private val prefs by lazy {
as ConnectivityManager context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
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")
}
} }
/**
* Startet WorkManager mit Network Constraints
* WorkManager kümmert sich automatisch um WiFi-Erkennung!
*/
fun startMonitoring() { 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) val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
Log.d(TAG, "Auto-sync enabled: $autoSyncEnabled")
if (!autoSyncEnabled) { if (!autoSyncEnabled) {
Log.d(TAG, "Auto-sync disabled, skipping") Logger.d(TAG, "Auto-sync disabled - stopping periodic work")
stopMonitoring()
return return
} }
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) Logger.d(TAG, "🚀 Starting WorkManager-based auto-sync")
Log.d(TAG, "Home SSID configured: $homeSSID")
if (isConnectedToHomeWifi()) { // Constraints: Nur wenn WiFi connected
Log.d(TAG, "✅ Connected to home WiFi, scheduling sync!") val constraints = Constraints.Builder()
scheduleSyncWork() .setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only
} else { .build()
Log.d(TAG, "❌ Not connected to home WiFi")
} // 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) * Stoppt WorkManager Auto-Sync
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false */
fun stopMonitoring() {
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) Logger.d(TAG, "🛑 Stopping auto-sync")
as WifiManager WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
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'") /**
* Trigger sofortigen Sync (z.B. nach Settings-Änderung)
// *** FIX #4: Better error handling for missing SSID *** */
if (currentSSID.isEmpty() || currentSSID == "<unknown ssid>") { private fun triggerImmediateSync() {
Log.w(TAG, "⚠️ Cannot get SSID - likely missing ACCESS_BACKGROUND_LOCATION permission!") if (!isConnectedToHomeWifi()) {
Log.w(TAG, "⚠️ On Android 12+, apps need 'Allow all the time' location permission") Logger.d(TAG, "Not on home WiFi - skipping immediate sync")
return false return
} }
val isHome = currentSSID == homeSSID Logger.d(TAG, "<EFBFBD> Triggering immediate sync...")
Log.d(TAG, "Is home WiFi: $isHome")
return isHome
}
private fun scheduleSyncWork() {
Log.d(TAG, "📅 Scheduling sync work...")
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>() val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
@@ -162,7 +88,72 @@ class NetworkMonitor(private val context: Context) {
.build() .build()
WorkManager.getInstance(context).enqueue(syncRequest) WorkManager.getInstance(context).enqueue(syncRequest)
}
Log.d(TAG, "✅ Sync work scheduled with WorkManager") /**
* Prüft ob connected zu Home WiFi via Gateway IP Check
*/
private fun isConnectedToHomeWifi(): Boolean {
val gatewayIP = getGatewayIP() ?: return false
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 package dev.dettmer.simplenotes.sync
import android.content.Context import android.content.Context
import android.util.Log import android.content.Intent
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -15,44 +17,77 @@ class SyncWorker(
companion object { companion object {
private const val TAG = "SyncWorker" private const val TAG = "SyncWorker"
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
} }
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { 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 { return@withContext try {
// Show notification that sync is starting // Start sync (kein "in progress" notification mehr)
NotificationHelper.showSyncInProgress(applicationContext)
Log.d(TAG, "📢 Notification shown: Sync in progress")
// Start sync
val syncService = WebDavSyncService(applicationContext) val syncService = WebDavSyncService(applicationContext)
Log.d(TAG, "🚀 Starting sync...") Logger.d(TAG, "🚀 Starting sync...")
Logger.d(TAG, "📊 Attempt: ${runAttemptCount}")
val result = syncService.syncNotes() val result = syncService.syncNotes()
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
if (result.isSuccess) { if (result.isSuccess) {
Log.d(TAG, "✅ Sync successful: ${result.syncedCount} notes") Logger.d(TAG, "✅ Sync successful: ${result.syncedCount} notes")
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
if (result.syncedCount > 0) {
NotificationHelper.showSyncSuccess( NotificationHelper.showSyncSuccess(
applicationContext, applicationContext,
result.syncedCount result.syncedCount
) )
} else {
Logger.d(TAG, " No changes to sync - no notification")
}
// **UI REFRESH**: Broadcast für MainActivity
broadcastSyncCompleted(true, result.syncedCount)
Result.success() Result.success()
} else { } else {
Log.e(TAG, "❌ Sync failed: ${result.errorMessage}") Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
NotificationHelper.showSyncError( NotificationHelper.showSyncError(
applicationContext, applicationContext,
result.errorMessage ?: "Unbekannter Fehler" result.errorMessage ?: "Unbekannter Fehler"
) )
// Broadcast auch bei Fehler (damit UI refresht)
broadcastSyncCompleted(false, 0)
Result.failure() Result.failure()
} }
} catch (e: Exception) { } 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( NotificationHelper.showSyncError(
applicationContext, applicationContext,
e.message ?: "Unknown error" e.message ?: "Unknown error"
) )
broadcastSyncCompleted(false, 0)
Result.failure() 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.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class WebDavSyncService(private val context: Context) { class WebDavSyncService(private val context: Context) {
companion object {
private const val TAG = "WebDavSyncService"
}
private val storage = NotesStorage(context) private val storage = NotesStorage(context)
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) 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 username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
val password = prefs.getString(Constants.KEY_PASSWORD, 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 { return OkHttpSardine().apply {
setCredentials(username, password) setCredentials(username, password)
} }
@@ -75,37 +83,59 @@ class WebDavSyncService(private val context: Context) {
} }
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) { 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 { return@withContext try {
val sardine = getSardine() ?: return@withContext SyncResult( val sardine = getSardine()
if (sardine == null) {
android.util.Log.e(TAG, "❌ Sardine is null - credentials missing")
return@withContext SyncResult(
isSuccess = false, isSuccess = false,
errorMessage = "Server-Zugangsdaten nicht konfiguriert" errorMessage = "Server-Zugangsdaten nicht konfiguriert"
) )
}
val serverUrl = getServerUrl() ?: return@withContext SyncResult( val serverUrl = getServerUrl()
if (serverUrl == null) {
android.util.Log.e(TAG, "❌ Server URL is null")
return@withContext SyncResult(
isSuccess = false, isSuccess = false,
errorMessage = "Server-URL nicht konfiguriert" 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 syncedCount = 0
var conflictCount = 0 var conflictCount = 0
// Ensure server directory exists // Ensure server directory exists
android.util.Log.d(TAG, "🔍 Checking if server directory exists...")
if (!sardine.exists(serverUrl)) { if (!sardine.exists(serverUrl)) {
android.util.Log.d(TAG, "📁 Creating server directory...")
sardine.createDirectory(serverUrl) sardine.createDirectory(serverUrl)
} }
// Upload local notes // Upload local notes
android.util.Log.d(TAG, "⬆️ Uploading local notes...")
val uploadedCount = uploadLocalNotes(sardine, serverUrl) val uploadedCount = uploadLocalNotes(sardine, serverUrl)
syncedCount += uploadedCount syncedCount += uploadedCount
android.util.Log.d(TAG, "✅ Uploaded: $uploadedCount notes")
// Download remote notes // Download remote notes
android.util.Log.d(TAG, "⬇️ Downloading remote notes...")
val downloadResult = downloadRemoteNotes(sardine, serverUrl) val downloadResult = downloadRemoteNotes(sardine, serverUrl)
syncedCount += downloadResult.downloadedCount syncedCount += downloadResult.downloadedCount
conflictCount += downloadResult.conflictCount conflictCount += downloadResult.conflictCount
android.util.Log.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
// Update last sync timestamp // Update last sync timestamp
saveLastSyncTimestamp() saveLastSyncTimestamp()
android.util.Log.d(TAG, "🎉 Sync completed successfully - Total synced: $syncedCount")
SyncResult( SyncResult(
isSuccess = true, isSuccess = true,
syncedCount = syncedCount, syncedCount = syncedCount,
@@ -113,11 +143,14 @@ class WebDavSyncService(private val context: Context) {
) )
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e(TAG, "💥 Sync exception: ${e.message}", e)
android.util.Log.e(TAG, "Exception type: ${e.javaClass.name}")
SyncResult( SyncResult(
isSuccess = false, isSuccess = false,
errorMessage = when (e) { errorMessage = when (e) {
is java.net.UnknownHostException -> "Server nicht erreichbar" is java.net.UnknownHostException -> "Server nicht erreichbar: ${e.message}"
is java.net.SocketTimeoutException -> "Verbindungs-Timeout" is java.net.SocketTimeoutException -> "Verbindungs-Timeout: ${e.message}"
is javax.net.ssl.SSLException -> "SSL-Fehler" is javax.net.ssl.SSLException -> "SSL-Fehler"
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> { is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
when (e.statusCode) { 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 * Zeigt Erfolgs-Notification
*/ */
fun showSyncSuccess(context: Context, count: Int) { 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) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync) .setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Sync erfolgreich") .setContentTitle("Sync erfolgreich")
.setContentText("$count Notizen synchronisiert") .setContentText("$count Notizen synchronisiert")
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true) .setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(pendingIntent) // Click öffnet App
.setAutoCancel(true) // Dismiss beim Click
.build() .build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
@@ -229,12 +242,25 @@ object NotificationHelper {
* Zeigt Fehler-Notification * Zeigt Fehler-Notification
*/ */
fun showSyncError(context: Context, message: String) { 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) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error) .setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Sync Fehler") .setContentTitle("Sync Fehler")
.setContentText(message) .setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true) .setCategory(NotificationCompat.CATEGORY_ERROR)
.setContentIntent(pendingIntent) // Click öffnet App
.setAutoCancel(true) // Dismiss beim Click
.build() .build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E3F2FD" />
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="#2196F3" />
</shape>

View File

@@ -90,38 +90,44 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:layout_marginBottom="16dp" /> android:layout_marginBottom="16dp" />
<!-- Server Status -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:orientation="horizontal" android:orientation="horizontal"
android:layout_marginBottom="8dp"> android:gravity="center_vertical">
<com.google.android.material.textfield.TextInputLayout <TextView
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:hint="@string/home_ssid" android:text="Server-Status:"
android:layout_marginEnd="8dp" android:textStyle="bold"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"> android:textSize="14sp" />
<com.google.android.material.textfield.TextInputEditText <TextView
android:id="@+id/editTextHomeSSID" android:id="@+id/textViewServerStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/buttonDetectSSID"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_vertical" android:text="Prüfe..."
android:text="Detect" android:textSize="14sp"
style="@style/Widget.Material3.Button.OutlinedButton" /> android:textColor="@android:color/darker_gray" />
</LinearLayout> </LinearLayout>
<!-- Info Box -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:padding="12dp"
android:background="@drawable/info_background"
android:text=" Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert nur im selben Netzwerk\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%/Tag)"
android:textSize="14sp"
android:lineSpacingMultiplier="1.2"
android:textColor="@android:color/black" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"