From c29542567feced11042538bce10adbe4967699d3 Mon Sep 17 00:00:00 2001 From: inventory69 Date: Sat, 20 Dec 2025 00:59:16 +0100 Subject: [PATCH] Implement complete Android app code - Add models: Note, SyncStatus - Add storage: NotesStorage for local file system - Add sync: WebDavSyncService, SyncWorker, WifiSyncReceiver - Add UI: MainActivity, NoteEditorActivity, SettingsActivity - Add adapters: NotesAdapter - Add utils: Constants, DeviceIdGenerator, Extensions, NotificationHelper - Add layouts: activity_main, activity_editor, activity_settings, item_note - Add menus and strings --- .../dev/dettmer/simplenotes/MainActivity.kt | 156 +++++++++++++- .../dettmer/simplenotes/NoteEditorActivity.kt | 134 ++++++++++++ .../dettmer/simplenotes/SettingsActivity.kt | 159 +++++++++++++++ .../simplenotes/adapters/NotesAdapter.kt | 66 ++++++ .../dev/dettmer/simplenotes/models/Note.kt | 48 +++++ .../dettmer/simplenotes/models/SyncStatus.kt | 8 + .../simplenotes/storage/NotesStorage.kt | 41 ++++ .../dettmer/simplenotes/sync/SyncResult.kt | 11 + .../dettmer/simplenotes/sync/SyncWorker.kt | 65 ++++++ .../simplenotes/sync/WebDavSyncService.kt | 178 ++++++++++++++++ .../simplenotes/sync/WifiSyncReceiver.kt | 62 ++++++ .../dettmer/simplenotes/utils/Constants.kt | 20 ++ .../simplenotes/utils/DeviceIdGenerator.kt | 39 ++++ .../dettmer/simplenotes/utils/Extensions.kt | 48 +++++ .../simplenotes/utils/NotificationHelper.kt | 192 ++++++++++++++++++ .../src/main/res/layout/activity_editor.xml | 52 +++++ .../app/src/main/res/layout/activity_main.xml | 51 ++++- .../src/main/res/layout/activity_settings.xml | 150 ++++++++++++++ android/app/src/main/res/layout/item_note.xml | 65 ++++++ android/app/src/main/res/menu/menu_editor.xml | 17 ++ android/app/src/main/res/menu/menu_main.xml | 17 ++ android/app/src/main/res/values/strings.xml | 22 +- 22 files changed, 1581 insertions(+), 20 deletions(-) create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/models/SyncStatus.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncResult.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/utils/DeviceIdGenerator.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/utils/Extensions.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt create mode 100644 android/app/src/main/res/layout/activity_editor.xml create mode 100644 android/app/src/main/res/layout/activity_settings.xml create mode 100644 android/app/src/main/res/layout/item_note.xml create mode 100644 android/app/src/main/res/menu/menu_editor.xml create mode 100644 android/app/src/main/res/menu/menu_main.xml diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt index 4ef60ac..a9a7779 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt @@ -1,20 +1,160 @@ package dev.dettmer.simplenotes +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle -import androidx.activity.enableEdgeToEdge +import android.view.Menu +import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat +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.utils.NotificationHelper +import dev.dettmer.simplenotes.utils.showToast +import android.widget.TextView class MainActivity : AppCompatActivity() { + + private lateinit var recyclerViewNotes: RecyclerView + private lateinit var textViewEmpty: TextView + private lateinit var fabAddNote: FloatingActionButton + private lateinit var toolbar: MaterialToolbar + + private lateinit var adapter: NotesAdapter + private val storage by lazy { NotesStorage(this) } + + companion object { + private const val REQUEST_NOTIFICATION_PERMISSION = 1001 + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets + + // Notification Channel erstellen + NotificationHelper.createNotificationChannel(this) + + // Permission für Notifications (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestNotificationPermission() + } + + findViews() + setupToolbar() + setupRecyclerView() + setupFab() + + loadNotes() + } + + override fun onResume() { + super.onResume() + loadNotes() + } + + private fun findViews() { + recyclerViewNotes = findViewById(R.id.recyclerViewNotes) + textViewEmpty = findViewById(R.id.textViewEmpty) + fabAddNote = findViewById(R.id.fabAddNote) + toolbar = findViewById(R.id.toolbar) + } + + private fun setupToolbar() { + setSupportActionBar(toolbar) + } + + private fun setupRecyclerView() { + adapter = NotesAdapter { note -> + openNoteEditor(note.id) + } + recyclerViewNotes.adapter = adapter + recyclerViewNotes.layoutManager = LinearLayoutManager(this) + } + + private fun setupFab() { + fabAddNote.setOnClickListener { + openNoteEditor(null) + } + } + + private fun loadNotes() { + val notes = storage.loadAllNotes() + adapter.submitList(notes) + + // Empty state + textViewEmpty.visibility = if (notes.isEmpty()) { + android.view.View.VISIBLE + } else { + android.view.View.GONE + } + } + + private fun openNoteEditor(noteId: String?) { + val intent = Intent(this, NoteEditorActivity::class.java) + noteId?.let { + intent.putExtra(NoteEditorActivity.EXTRA_NOTE_ID, it) + } + startActivity(intent) + } + + private fun openSettings() { + startActivity(Intent(this, SettingsActivity::class.java)) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_main, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_settings -> { + openSettings() + true + } + R.id.action_sync -> { + // Manual sync trigger could be added here + showToast("Sync wird in den Einstellungen gestartet") + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions( + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + REQUEST_NOTIFICATION_PERMISSION + ) + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + when (requestCode) { + REQUEST_NOTIFICATION_PERMISSION -> { + if (grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED) { + showToast("Benachrichtigungen aktiviert") + } else { + showToast("Benachrichtigungen deaktiviert. " + + "Du kannst sie in den Einstellungen aktivieren.") + } + } } } } \ No newline at end of file diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt new file mode 100644 index 0000000..418d9be --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt @@ -0,0 +1,134 @@ +package dev.dettmer.simplenotes + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.models.SyncStatus +import dev.dettmer.simplenotes.storage.NotesStorage +import dev.dettmer.simplenotes.utils.DeviceIdGenerator +import dev.dettmer.simplenotes.utils.showToast +import com.google.android.material.textfield.TextInputEditText + +class NoteEditorActivity : AppCompatActivity() { + + private lateinit var editTextTitle: TextInputEditText + private lateinit var editTextContent: TextInputEditText + private lateinit var storage: NotesStorage + + private var existingNote: Note? = null + + companion object { + const val EXTRA_NOTE_ID = "extra_note_id" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_editor) + + storage = NotesStorage(this) + + // Setup toolbar + supportActionBar?.apply { + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel) + } + + // Find views + editTextTitle = findViewById(R.id.editTextTitle) + editTextContent = findViewById(R.id.editTextContent) + + // Load existing note if editing + val noteId = intent.getStringExtra(EXTRA_NOTE_ID) + if (noteId != null) { + existingNote = storage.loadNote(noteId) + existingNote?.let { + editTextTitle.setText(it.title) + editTextContent.setText(it.content) + supportActionBar?.title = "Notiz bearbeiten" + } + } else { + supportActionBar?.title = "Neue Notiz" + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_editor, menu) + + // Show delete only for existing notes + menu.findItem(R.id.action_delete)?.isVisible = existingNote != null + + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + R.id.action_save -> { + saveNote() + true + } + R.id.action_delete -> { + confirmDelete() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun saveNote() { + val title = editTextTitle.text?.toString()?.trim() ?: "" + val content = editTextContent.text?.toString()?.trim() ?: "" + + if (title.isEmpty() && content.isEmpty()) { + showToast("Titel oder Inhalt darf nicht leer sein") + return + } + + val note = if (existingNote != null) { + // Update existing note + existingNote!!.copy( + title = title, + content = content, + updatedAt = System.currentTimeMillis(), + syncStatus = SyncStatus.PENDING + ) + } else { + // Create new note + Note( + title = title, + content = content, + deviceId = DeviceIdGenerator.getDeviceId(this), + syncStatus = SyncStatus.LOCAL_ONLY + ) + } + + storage.saveNote(note) + showToast("Notiz gespeichert") + finish() + } + + private fun confirmDelete() { + AlertDialog.Builder(this) + .setTitle("Notiz löschen?") + .setMessage("Diese Aktion kann nicht rückgängig gemacht werden.") + .setPositiveButton("Löschen") { _, _ -> + deleteNote() + } + .setNegativeButton("Abbrechen", null) + .show() + } + + private fun deleteNote() { + existingNote?.let { + storage.deleteNote(it.id) + showToast("Notiz gelöscht") + finish() + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt new file mode 100644 index 0000000..22c50ba --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt @@ -0,0 +1,159 @@ +package dev.dettmer.simplenotes + +import android.net.wifi.WifiManager +import android.os.Bundle +import android.view.MenuItem +import android.widget.Button +import android.widget.EditText +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SwitchCompat +import androidx.lifecycle.lifecycleScope +import dev.dettmer.simplenotes.sync.WebDavSyncService +import dev.dettmer.simplenotes.utils.Constants +import dev.dettmer.simplenotes.utils.showToast +import kotlinx.coroutines.launch + +class SettingsActivity : AppCompatActivity() { + + 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 val prefs by lazy { + getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + + supportActionBar?.apply { + setDisplayHomeAsUpEnabled(true) + title = "Einstellungen" + } + + findViews() + loadSettings() + setupListeners() + } + + private fun findViews() { + 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) + } + + 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) + } + + private fun setupListeners() { + buttonTestConnection.setOnClickListener { + saveSettings() + testConnection() + } + + buttonSyncNow.setOnClickListener { + saveSettings() + syncNow() + } + + buttonDetectSSID.setOnClickListener { + detectCurrentSSID() + } + } + + private fun saveSettings() { + prefs.edit().apply { + 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() + } + } + + private fun testConnection() { + lifecycleScope.launch { + try { + showToast("Teste Verbindung...") + val syncService = WebDavSyncService(this@SettingsActivity) + val result = syncService.syncNotes() + + if (result.isSuccess) { + showToast("Verbindung erfolgreich! ${result.syncedCount} Notizen synchronisiert") + } else { + showToast("Verbindung fehlgeschlagen: ${result.errorMessage}") + } + } catch (e: Exception) { + showToast("Fehler: ${e.message}") + } + } + } + + private fun syncNow() { + lifecycleScope.launch { + try { + showToast("Synchronisiere...") + val syncService = WebDavSyncService(this@SettingsActivity) + val result = syncService.syncNotes() + + if (result.isSuccess) { + if (result.hasConflicts) { + showToast("Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!") + } else { + showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert") + } + } else { + showToast("Sync fehlgeschlagen: ${result.errorMessage}") + } + } catch (e: Exception) { + showToast("Fehler: ${e.message}") + } + } + } + + private fun detectCurrentSSID() { + val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager + val wifiInfo = wifiManager.connectionInfo + val ssid = wifiInfo.ssid.replace("\"", "") + + if (ssid.isNotEmpty() && ssid != "") { + editTextHomeSSID.setText(ssid) + showToast("SSID erkannt: $ssid") + } else { + showToast("Nicht mit WLAN verbunden") + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + saveSettings() + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onPause() { + super.onPause() + saveSettings() + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt b/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt new file mode 100644 index 0000000..6f53d59 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt @@ -0,0 +1,66 @@ +package dev.dettmer.simplenotes.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.models.SyncStatus +import dev.dettmer.simplenotes.utils.toReadableTime +import dev.dettmer.simplenotes.utils.truncate + +class NotesAdapter( + private val onNoteClick: (Note) -> Unit +) : ListAdapter(NoteDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_note, parent, false) + return NoteViewHolder(view) + } + + override fun onBindViewHolder(holder: NoteViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle) + private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent) + private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp) + private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus) + + fun bind(note: Note) { + textViewTitle.text = note.title.ifEmpty { "Ohne Titel" } + textViewContent.text = note.content.truncate(100) + textViewTimestamp.text = note.updatedAt.toReadableTime() + + // Sync status icon + val syncIcon = when (note.syncStatus) { + SyncStatus.SYNCED -> android.R.drawable.ic_menu_upload + SyncStatus.PENDING -> android.R.drawable.ic_popup_sync + SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert + SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save + } + imageViewSyncStatus.setImageResource(syncIcon) + + itemView.setOnClickListener { + onNoteClick(note) + } + } + } + + private class NoteDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Note, newItem: Note): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Note, newItem: Note): Boolean { + return oldItem == newItem + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt new file mode 100644 index 0000000..f02a6b5 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt @@ -0,0 +1,48 @@ +package dev.dettmer.simplenotes.models + +import java.util.UUID + +data class Note( + val id: String = UUID.randomUUID().toString(), + val title: String, + val content: String, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), + val deviceId: String, + val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY +) { + fun toJson(): String { + return """ + { + "id": "$id", + "title": "${title.escapeJson()}", + "content": "${content.escapeJson()}", + "createdAt": $createdAt, + "updatedAt": $updatedAt, + "deviceId": "$deviceId", + "syncStatus": "${syncStatus.name}" + } + """.trimIndent() + } + + companion object { + fun fromJson(json: String): Note? { + return try { + val gson = com.google.gson.Gson() + gson.fromJson(json, Note::class.java) + } catch (e: Exception) { + null + } + } + } +} + +// Extension für JSON-Escaping +fun String.escapeJson(): String { + return this + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/SyncStatus.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/SyncStatus.kt new file mode 100644 index 0000000..c1aea44 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/SyncStatus.kt @@ -0,0 +1,8 @@ +package dev.dettmer.simplenotes.models + +enum class SyncStatus { + LOCAL_ONLY, // Noch nie gesynct + SYNCED, // Erfolgreich gesynct + PENDING, // Wartet auf Sync + CONFLICT // Konflikt erkannt +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt new file mode 100644 index 0000000..17dfcd0 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt @@ -0,0 +1,41 @@ +package dev.dettmer.simplenotes.storage + +import android.content.Context +import dev.dettmer.simplenotes.models.Note +import java.io.File + +class NotesStorage(private val context: Context) { + + private val notesDir: File = File(context.filesDir, "notes").apply { + if (!exists()) mkdirs() + } + + fun saveNote(note: Note) { + val file = File(notesDir, "${note.id}.json") + file.writeText(note.toJson()) + } + + fun loadNote(id: String): Note? { + val file = File(notesDir, "$id.json") + return if (file.exists()) { + Note.fromJson(file.readText()) + } else { + null + } + } + + fun loadAllNotes(): List { + return notesDir.listFiles() + ?.filter { it.extension == "json" } + ?.mapNotNull { Note.fromJson(it.readText()) } + ?.sortedByDescending { it.updatedAt } + ?: emptyList() + } + + fun deleteNote(id: String): Boolean { + val file = File(notesDir, "$id.json") + return file.delete() + } + + fun getNotesDir(): File = notesDir +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncResult.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncResult.kt new file mode 100644 index 0000000..3aa986a --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncResult.kt @@ -0,0 +1,11 @@ +package dev.dettmer.simplenotes.sync + +data class SyncResult( + val isSuccess: Boolean, + val syncedCount: Int = 0, + val conflictCount: Int = 0, + val errorMessage: String? = null +) { + val hasConflicts: Boolean + get() = conflictCount > 0 +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt new file mode 100644 index 0000000..7965527 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt @@ -0,0 +1,65 @@ +package dev.dettmer.simplenotes.sync + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dev.dettmer.simplenotes.utils.NotificationHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SyncWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + // Progress Notification zeigen + val notificationId = NotificationHelper.showSyncProgressNotification(applicationContext) + + return@withContext try { + val syncService = WebDavSyncService(applicationContext) + val syncResult = syncService.syncNotes() + + // Progress Notification entfernen + NotificationHelper.dismissNotification(applicationContext, notificationId) + + when { + syncResult.hasConflicts -> { + // Konflikt-Notification + NotificationHelper.showConflictNotification( + applicationContext, + syncResult.conflictCount + ) + Result.success() + } + syncResult.isSuccess -> { + // Erfolgs-Notification + NotificationHelper.showSyncSuccessNotification( + applicationContext, + syncResult.syncedCount + ) + Result.success() + } + else -> { + // Fehler-Notification + NotificationHelper.showSyncFailureNotification( + applicationContext, + syncResult.errorMessage ?: "Unbekannter Fehler" + ) + Result.retry() + } + } + + } catch (e: Exception) { + // Fehler-Notification + NotificationHelper.dismissNotification(applicationContext, notificationId) + NotificationHelper.showSyncFailureNotification( + applicationContext, + e.message ?: "Sync fehlgeschlagen" + ) + + // Retry mit Backoff + Result.retry() + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt new file mode 100644 index 0000000..76013b6 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt @@ -0,0 +1,178 @@ +package dev.dettmer.simplenotes.sync + +import android.content.Context +import com.thegrizzlylabs.sardineandroid.Sardine +import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream + +class WebDavSyncService(private val context: Context) { + + private val storage = NotesStorage(context) + private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) + + private fun getSardine(): Sardine? { + val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null + val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null + + return OkHttpSardine().apply { + setCredentials(username, password) + } + } + + private fun getServerUrl(): String? { + return prefs.getString(Constants.KEY_SERVER_URL, null) + } + + suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) { + return@withContext try { + val sardine = getSardine() ?: return@withContext SyncResult( + isSuccess = false, + errorMessage = "Server-Zugangsdaten nicht konfiguriert" + ) + + val serverUrl = getServerUrl() ?: return@withContext SyncResult( + isSuccess = false, + errorMessage = "Server-URL nicht konfiguriert" + ) + + var syncedCount = 0 + var conflictCount = 0 + + // Ensure server directory exists + if (!sardine.exists(serverUrl)) { + sardine.createDirectory(serverUrl) + } + + // Upload local notes + val uploadedCount = uploadLocalNotes(sardine, serverUrl) + syncedCount += uploadedCount + + // Download remote notes + val downloadResult = downloadRemoteNotes(sardine, serverUrl) + syncedCount += downloadResult.downloadedCount + conflictCount += downloadResult.conflictCount + + // Update last sync timestamp + saveLastSyncTimestamp() + + SyncResult( + isSuccess = true, + syncedCount = syncedCount, + conflictCount = conflictCount + ) + + } catch (e: Exception) { + SyncResult( + isSuccess = false, + errorMessage = when (e) { + is java.net.UnknownHostException -> "Server nicht erreichbar" + is java.net.SocketTimeoutException -> "Verbindungs-Timeout" + is javax.net.ssl.SSLException -> "SSL-Fehler" + is com.thegrizzlylabs.sardineandroid.impl.SardineException -> { + when (e.statusCode) { + 401 -> "Authentifizierung fehlgeschlagen" + 403 -> "Zugriff verweigert" + 404 -> "Server-Pfad nicht gefunden" + 500 -> "Server-Fehler" + else -> "HTTP-Fehler: ${e.statusCode}" + } + } + else -> e.message ?: "Unbekannter Fehler" + } + ) + } + } + + private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int { + var uploadedCount = 0 + val localNotes = storage.loadAllNotes() + + for (note in localNotes) { + try { + if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) { + val noteUrl = "$serverUrl/${note.id}.json" + val jsonBytes = note.toJson().toByteArray() + + sardine.put(noteUrl, ByteArrayInputStream(jsonBytes), "application/json") + + // Update sync status + val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED) + storage.saveNote(updatedNote) + uploadedCount++ + } + } catch (e: Exception) { + // Mark as pending for retry + val updatedNote = note.copy(syncStatus = SyncStatus.PENDING) + storage.saveNote(updatedNote) + } + } + + return uploadedCount + } + + private data class DownloadResult( + val downloadedCount: Int, + val conflictCount: Int + ) + + private fun downloadRemoteNotes(sardine: Sardine, serverUrl: String): DownloadResult { + var downloadedCount = 0 + var conflictCount = 0 + + try { + val resources = sardine.list(serverUrl) + + for (resource in resources) { + if (resource.isDirectory || !resource.name.endsWith(".json")) { + continue + } + + val noteUrl = resource.href.toString() + val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() } + val remoteNote = Note.fromJson(jsonContent) ?: continue + + val localNote = storage.loadNote(remoteNote.id) + + when { + localNote == null -> { + // New note from server + storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) + downloadedCount++ + } + localNote.updatedAt < remoteNote.updatedAt -> { + // Remote is newer + if (localNote.syncStatus == SyncStatus.PENDING) { + // Conflict detected + storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) + conflictCount++ + } else { + // Safe to overwrite + storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) + downloadedCount++ + } + } + } + } + } catch (e: Exception) { + // Log error but don't fail entire sync + } + + return DownloadResult(downloadedCount, conflictCount) + } + + private fun saveLastSyncTimestamp() { + prefs.edit() + .putLong(Constants.KEY_LAST_SYNC, System.currentTimeMillis()) + .apply() + } + + fun getLastSyncTimestamp(): Long { + return prefs.getLong(Constants.KEY_LAST_SYNC, 0) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt new file mode 100644 index 0000000..bc61138 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WifiSyncReceiver.kt @@ -0,0 +1,62 @@ +package dev.dettmer.simplenotes.sync + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import dev.dettmer.simplenotes.utils.Constants +import java.util.concurrent.TimeUnit + +class WifiSyncReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + // Check if auto-sync is enabled + val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) + val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) + + if (!autoSyncEnabled) { + return + } + + // Check if connected to home WiFi + if (isConnectedToHomeWifi(context)) { + scheduleSyncWork(context) + } + } + + private fun isConnectedToHomeWifi(context: Context): Boolean { + val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) + val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false + + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) + as ConnectivityManager + + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + + if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + return false + } + + // Get current SSID + val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) + as WifiManager + val wifiInfo = wifiManager.connectionInfo + val currentSSID = wifiInfo.ssid.replace("\"", "") + + return currentSSID == homeSSID + } + + private fun scheduleSyncWork(context: Context) { + val syncRequest = OneTimeWorkRequestBuilder() + .setInitialDelay(Constants.SYNC_DELAY_SECONDS, TimeUnit.SECONDS) + .addTag(Constants.SYNC_WORK_TAG) + .build() + + WorkManager.getInstance(context).enqueue(syncRequest) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt new file mode 100644 index 0000000..b827225 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt @@ -0,0 +1,20 @@ +package dev.dettmer.simplenotes.utils + +object Constants { + // SharedPreferences + const val PREFS_NAME = "simple_notes_prefs" + const val KEY_SERVER_URL = "server_url" + const val KEY_USERNAME = "username" + const val KEY_PASSWORD = "password" + const val KEY_HOME_SSID = "home_ssid" + const val KEY_AUTO_SYNC = "auto_sync_enabled" + const val KEY_LAST_SYNC = "last_sync_timestamp" + + // WorkManager + const val SYNC_WORK_TAG = "notes_sync" + const val SYNC_DELAY_SECONDS = 5L + + // Notifications + const val NOTIFICATION_CHANNEL_ID = "notes_sync_channel" + const val NOTIFICATION_ID = 1001 +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/DeviceIdGenerator.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/DeviceIdGenerator.kt new file mode 100644 index 0000000..178ab7c --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/DeviceIdGenerator.kt @@ -0,0 +1,39 @@ +package dev.dettmer.simplenotes.utils + +import android.content.Context +import android.provider.Settings +import java.util.UUID + +object DeviceIdGenerator { + + private const val PREF_NAME = "simple_notes_prefs" + private const val KEY_DEVICE_ID = "device_id" + + fun getDeviceId(context: Context): String { + val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + // Check if already generated + var deviceId = prefs.getString(KEY_DEVICE_ID, null) + + if (deviceId == null) { + // Try Android ID + deviceId = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ANDROID_ID + ) + + // Fallback to UUID if Android ID not available + if (deviceId.isNullOrEmpty()) { + deviceId = UUID.randomUUID().toString() + } + + // Prefix for identification + deviceId = "android-$deviceId" + + // Save for future use + prefs.edit().putString(KEY_DEVICE_ID, deviceId).apply() + } + + return deviceId + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Extensions.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Extensions.kt new file mode 100644 index 0000000..747388e --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Extensions.kt @@ -0,0 +1,48 @@ +package dev.dettmer.simplenotes.utils + +import android.content.Context +import android.widget.Toast +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +// Toast Extensions +fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(this, message, duration).show() +} + +// Timestamp to readable format +fun Long.toReadableTime(): String { + val now = System.currentTimeMillis() + val diff = now - this + + return when { + diff < TimeUnit.MINUTES.toMillis(1) -> "Gerade eben" + diff < TimeUnit.HOURS.toMillis(1) -> { + val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) + "Vor $minutes Min" + } + diff < TimeUnit.DAYS.toMillis(1) -> { + val hours = TimeUnit.MILLISECONDS.toHours(diff) + "Vor $hours Std" + } + diff < TimeUnit.DAYS.toMillis(7) -> { + val days = TimeUnit.MILLISECONDS.toDays(diff) + "Vor $days Tagen" + } + else -> { + val sdf = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN) + sdf.format(Date(this)) + } + } +} + +// Truncate long strings +fun String.truncate(maxLength: Int): String { + return if (length > maxLength) { + substring(0, maxLength - 3) + "..." + } else { + this + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt new file mode 100644 index 0000000..20e82d8 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt @@ -0,0 +1,192 @@ +package dev.dettmer.simplenotes.utils + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import dev.dettmer.simplenotes.MainActivity + +object NotificationHelper { + + private const val CHANNEL_ID = "notes_sync_channel" + private const val CHANNEL_NAME = "Notizen Synchronisierung" + private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status" + private const val NOTIFICATION_ID = 1001 + + /** + * Erstellt Notification Channel (Android 8.0+) + * Muss beim App-Start aufgerufen werden + */ + fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_DEFAULT + + val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, importance).apply { + description = CHANNEL_DESCRIPTION + enableVibration(true) + enableLights(true) + } + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) + as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + /** + * Zeigt Erfolgs-Notification nach Sync + */ + fun showSyncSuccessNotification(context: Context, syncedCount: Int) { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + 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.ic_menu_upload) + .setContentTitle("Sync erfolgreich") + .setContentText("$syncedCount Notiz(en) synchronisiert") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + with(NotificationManagerCompat.from(context)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (androidx.core.app.ActivityCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + notify(NOTIFICATION_ID, notification) + } + } else { + notify(NOTIFICATION_ID, notification) + } + } + } + + /** + * Zeigt Fehler-Notification bei fehlgeschlagenem Sync + */ + fun showSyncFailureNotification(context: Context, errorMessage: String) { + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setContentTitle("Sync fehlgeschlagen") + .setContentText(errorMessage) + .setStyle(NotificationCompat.BigTextStyle() + .bigText(errorMessage)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .build() + + with(NotificationManagerCompat.from(context)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (androidx.core.app.ActivityCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + notify(NOTIFICATION_ID, notification) + } + } else { + notify(NOTIFICATION_ID, notification) + } + } + } + + /** + * Zeigt Progress-Notification während Sync läuft + */ + fun showSyncProgressNotification(context: Context): Int { + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_popup_sync) + .setContentTitle("Synchronisiere...") + .setContentText("Notizen werden synchronisiert") + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setProgress(0, 0, true) + .build() + + with(NotificationManagerCompat.from(context)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (androidx.core.app.ActivityCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + notify(NOTIFICATION_ID, notification) + } + } else { + notify(NOTIFICATION_ID, notification) + } + } + + return NOTIFICATION_ID + } + + /** + * Zeigt Notification bei erkanntem Konflikt + */ + fun showConflictNotification(context: Context, conflictCount: Int) { + val intent = Intent(context, MainActivity::class.java) + 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.ic_dialog_info) + .setContentTitle("Sync-Konflikt erkannt") + .setContentText("$conflictCount Notiz(en) haben Konflikte") + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + with(NotificationManagerCompat.from(context)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (androidx.core.app.ActivityCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + notify(NOTIFICATION_ID + 1, notification) + } + } else { + notify(NOTIFICATION_ID + 1, notification) + } + } + } + + /** + * Entfernt aktive Notification + */ + fun dismissNotification(context: Context, notificationId: Int = NOTIFICATION_ID) { + with(NotificationManagerCompat.from(context)) { + cancel(notificationId) + } + } + + /** + * Prüft ob Notification-Permission vorhanden (Android 13+) + */ + fun hasNotificationPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + androidx.core.app.ActivityCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + true + } + } +} diff --git a/android/app/src/main/res/layout/activity_editor.xml b/android/app/src/main/res/layout/activity_editor.xml new file mode 100644 index 0000000..ff40739 --- /dev/null +++ b/android/app/src/main/res/layout/activity_editor.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 86a5d97..0f4c342 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,48 @@ - + android:layout_height="match_parent"> + + + + + + + + + android:layout_gravity="center" + android:text="@string/no_notes_yet" + android:textSize="18sp" + android:textColor="?android:attr/textColorSecondary" + android:gravity="center" + android:visibility="gone" /> - \ No newline at end of file + + + diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..f33aa87 --- /dev/null +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +