package com.anas4tech.noteable import android.app.ActivityManager import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.BroadcastReceiver import android.content.IntentFilter import android.content.Intent import android.content.pm.ServiceInfo import android.graphics.Color import android.graphics.PixelFormat import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.net.Uri import android.os.Build import android.os.IBinder import android.provider.Settings import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.WindowManager import android.widget.Button import android.widget.CheckBox import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView import es.antonborri.home_widget.HomeWidgetLaunchIntent class EdgeNotesOverlayService : Service() { private lateinit var windowManager: WindowManager private var overlayView: View? = null private var overlayParams: WindowManager.LayoutParams? = null private var expanded = false private var selectedNoteId: Int? = null private var configurationReceiverRegistered = false private val systemConfigurationReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { // In System mode the floating overlay is built from native Views, // so rebuild it when Android switches between day and night mode. if (overlayView != null && WidgetTheme.palette(this@EdgeNotesOverlayService).followsSystem) { showOverlay(expanded) } } } override fun onCreate() { super.onCreate() windowManager = getSystemService(WINDOW_SERVICE) as WindowManager registerSystemConfigurationReceiver() createNotificationChannel() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForeground( OVERLAY_NOTIFICATION_ID, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE ) } else { startForeground(OVERLAY_NOTIFICATION_ID, buildNotification()) } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { stopSelf() return START_NOT_STICKY } ACTION_REFRESH -> { if (overlayView != null) { showOverlay(expanded) } return START_STICKY } else -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this) ) { stopSelf() return START_NOT_STICKY } showOverlay(expanded = false) } } return START_STICKY } override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { if (configurationReceiverRegistered) { try { unregisterReceiver(systemConfigurationReceiver) } catch (_: IllegalArgumentException) { // Already unregistered by Android during process shutdown. } configurationReceiverRegistered = false } overlayView?.let { view -> if (view.isAttachedToWindow) { windowManager.removeView(view) } } overlayView = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) } else { @Suppress("DEPRECATION") stopForeground(true) } super.onDestroy() } private fun registerSystemConfigurationReceiver() { if (configurationReceiverRegistered) return val filter = IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(systemConfigurationReceiver, filter, Context.RECEIVER_NOT_EXPORTED) } else { @Suppress("DEPRECATION") registerReceiver(systemConfigurationReceiver, filter) } configurationReceiverRegistered = true } private fun showOverlay(expanded: Boolean) { this.expanded = expanded overlayView?.let { view -> if (view.isAttachedToWindow) { windowManager.removeView(view) } overlayView = null } val view = if (expanded) buildExpandedPanel() else buildEdgeHandle() val params = WindowManager.LayoutParams( if (expanded) panelWidth() else dp(30), if (expanded) panelHeight() else dp(64), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } else { @Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_PHONE }, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, PixelFormat.TRANSLUCENT ).apply { gravity = Gravity.END or Gravity.CENTER_VERTICAL x = 0 y = 0 } overlayView = view overlayParams = params windowManager.addView(view, params) } private fun buildEdgeHandle(): View { val palette = WidgetTheme.palette(this) return TextView(this).apply { text = "≡" textSize = 22f setTextColor(Color.WHITE) gravity = Gravity.CENTER background = roundedBackground(palette.edgeHandle, 16f) contentDescription = "Open recent notes" setOnClickListener { showOverlay(expanded = true) } setOnTouchListener(createDragListener()) } } private fun buildExpandedPanel(): View { val palette = WidgetTheme.palette(this) val panel = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL background = panelBackground() clipToOutline = true elevation = dp(12).toFloat() setPadding(0, 0, 0, dp(4)) } val selectedNote = selectedNoteId?.let { noteId -> findNote(widgetData(), noteId) } val header = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL setPadding(dp(12), dp(10), dp(10), dp(10)) } if (selectedNote == null) { header.addView(TextView(this).apply { text = "▤" textSize = 22f gravity = Gravity.CENTER setTextColor(if (palette.isDark) 0xFF202124.toInt() else Color.WHITE) background = roundedBackground(palette.accent, 12f) contentDescription = "Noteable" }, LinearLayout.LayoutParams(dp(40), dp(40))) } else { header.addView(actionButton("‹", "Back to recent notes", false) { selectedNoteId = null showOverlay(expanded = true) }) } header.addView(TextView(this).apply { text = if (selectedNote == null) "Recent Notes" else selectedNote.title.ifEmpty { "Untitled note" } textSize = 16f setTextColor(palette.primaryText) setTypeface(typeface, Typeface.BOLD) maxLines = 1 ellipsize = android.text.TextUtils.TruncateAt.END gravity = Gravity.CENTER_VERTICAL setPadding(dp(10), 0, dp(4), 0) }, LinearLayout.LayoutParams(0, dp(44), 1f)) if (selectedNote != null) { header.addView(actionButton("✎", "Edit note", true) { openNote(selectedNote.id) }) } else { header.addView(actionButton("+", "Create note", true) { openCreateNote() }) } header.addView(actionButton("×", "Close recent notes", false) { if (selectedNote != null) { selectedNoteId = null } showOverlay(expanded = false) }) panel.addView(header) panel.addView(View(this).apply { setBackgroundColor(palette.divider) }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1))) if (selectedNote != null) { val bodyScroll = ScrollView(this).apply { isFillViewport = true isVerticalScrollBarEnabled = true } val detailContent = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setPadding(dp(10), dp(8), dp(10), dp(12)) } detailContent.addView(TextView(this).apply { text = selectedNote.body.ifEmpty { "This note is empty." } textSize = 14f setTextColor(palette.secondaryText) setLineSpacing(dp(3).toFloat(), 1f) setPadding(dp(6), dp(6), dp(6), dp(12)) gravity = Gravity.TOP or Gravity.START }) if (selectedNote.checklist.isNotEmpty()) { detailContent.addView(View(this).apply { setBackgroundColor(palette.divider) }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1))) selectedNote.checklist.forEach { item -> detailContent.addView(buildChecklistCheckBox(selectedNote.id, item)) } } bodyScroll.addView(detailContent) panel.addView(bodyScroll, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f )) return panel } val scrollView = ScrollView(this).apply { isFillViewport = true isVerticalScrollBarEnabled = true setPadding(dp(8), dp(6), dp(8), dp(6)) clipToPadding = false } val rows = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } val data = widgetData() val count = data.getInt("notes_count", 0).coerceIn(0, MAX_NOTES) var visibleNotes = 0 for (index in 0 until count) { val title = data.getString("note_${index}_title", "") ?: "" val body = data.getString("note_${index}_body", "") ?: "" val noteId = data.getInt("note_${index}_id", -1) if (title.isEmpty() && body.isEmpty() && checklistForNote(data, index).isEmpty()) continue visibleNotes++ val row = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setPadding(dp(10), dp(7), dp(10), dp(7)) background = roundedBackground(0x00FFFFFF, 12f) } val noteSummary = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL isClickable = true isFocusable = true contentDescription = "Open ${title.ifEmpty { "Untitled note" }}" setOnClickListener { if (noteId > 0) { selectedNoteId = noteId showOverlay(expanded = true) } } } noteSummary.addView(TextView(this).apply { text = "▤" textSize = 19f gravity = Gravity.CENTER setTextColor(palette.noteIconText) background = roundedBackground(palette.noteIconBackground, 12f) contentDescription = "Note" }, LinearLayout.LayoutParams(dp(44), dp(44))) val textColumn = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER_VERTICAL setPadding(dp(10), 0, dp(4), 0) } textColumn.addView(TextView(this).apply { text = title.ifEmpty { "Untitled note" } textSize = 14f setTextColor(palette.primaryText) setTypeface(typeface, Typeface.BOLD) maxLines = 1 ellipsize = android.text.TextUtils.TruncateAt.END }) if (body.isNotEmpty()) { textColumn.addView(TextView(this).apply { text = body textSize = 12f setTextColor(palette.secondaryText) maxLines = 2 ellipsize = android.text.TextUtils.TruncateAt.END setPadding(0, dp(3), 0, 0) }) } noteSummary.addView(textColumn, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) row.addView(noteSummary, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT )) val checks = checklistForNote(data, index) checks.forEach { item -> row.addView(buildChecklistCheckBox(noteId, item)) } rows.addView(row, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT )) if (index < count - 1) { rows.addView(View(this).apply { setBackgroundColor(palette.divider) }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1))) } } if (visibleNotes == 0) { rows.addView(TextView(this).apply { text = "No notes yet\nTap + to create your first note." textSize = 13f setTextColor(palette.secondaryText) gravity = Gravity.CENTER setLineSpacing(dp(4).toFloat(), 1f) setPadding(dp(12), dp(28), dp(12), dp(28)) }, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT )) } scrollView.addView(rows) panel.addView(scrollView, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f )) return panel } private fun actionButton( label: String, description: String, primary: Boolean, onClick: () -> Unit ): TextView { return TextView(this).apply { text = label textSize = if (label == "+") 25f else 21f gravity = Gravity.CENTER val palette = WidgetTheme.palette(this@EdgeNotesOverlayService) setTextColor(if (primary) palette.accentText else palette.neutralText) background = roundedBackground( if (primary) palette.accentSoft else palette.neutralBackground, 12f ) contentDescription = description isClickable = true isFocusable = true setOnClickListener { onClick() } }.also { it.layoutParams = LinearLayout.LayoutParams(dp(38), dp(38)).apply { marginStart = dp(5) } } } private fun panelBackground() = GradientDrawable().apply { val palette = WidgetTheme.palette(this@EdgeNotesOverlayService) setColor(palette.background) cornerRadius = dp(18).toFloat() setStroke(dp(1), palette.border) } private fun widgetData() = getSharedPreferences(PREFERENCES, MODE_PRIVATE) private fun checklistForNote( data: android.content.SharedPreferences, index: Int ): List { val count = data.getInt("note_${index}_check_count", 0).coerceIn(0, MAX_CHECKLIST_ITEMS) return (0 until count).mapNotNull { itemIndex -> val id = data.getInt("note_${index}_check_${itemIndex}_id", -1) if (id <= 0) return@mapNotNull null OverlayCheckItem( id = id, content = data.getString("note_${index}_check_${itemIndex}_content", "") ?: "", completed = data.getBoolean("widget_check_${id}_completed", false) ) } } private fun buildChecklistCheckBox(noteId: Int, item: OverlayCheckItem): CheckBox { return CheckBox(this).apply { text = item.content isChecked = item.completed textSize = 12f val palette = WidgetTheme.palette(this@EdgeNotesOverlayService) setTextColor(palette.secondaryText) buttonTintList = android.content.res.ColorStateList.valueOf(palette.checkboxTint) maxLines = 2 setPadding(dp(2), dp(1), dp(4), dp(1)) minHeight = dp(34) setOnClickListener { if (noteId > 0) { WidgetItemClickReceiver.toggleChecklistItem( this@EdgeNotesOverlayService, noteId, item.id, refreshEdgeOverlay = false ) showOverlay(expanded = true) } } } } private fun findNote( widgetData: android.content.SharedPreferences, noteId: Int ): OverlayNote? { val count = widgetData.getInt("notes_count", 0).coerceIn(0, MAX_NOTES) for (index in 0 until count) { if (widgetData.getInt("note_${index}_id", -1) == noteId) { return OverlayNote( id = noteId, title = widgetData.getString("note_${index}_title", "") ?: "", body = widgetData.getString("note_${index}_body", "") ?: "", checklist = checklistForNote(widgetData, index) ) } } return null } private fun openNote(noteId: Int) { showOverlay(expanded = false) val launchIntent = HomeWidgetLaunchIntent.getActivity( this, OverlayCreateNoteActivity::class.java, Uri.parse( "noteable://open-note/$noteId?mode=popup&source=floating_view" ) ) launchIntent.send() } private fun openCreateNote() { showOverlay(expanded = false) val launchIntent = HomeWidgetLaunchIntent.getActivity( this, OverlayCreateNoteActivity::class.java, Uri.parse( "noteable://create-note?mode=popup&source=floating_view&request=${System.currentTimeMillis()}" ) ) launchIntent.send() } private fun createDragListener(): View.OnTouchListener { var startY = 0f var initialOffset = 0 var dragged = false return View.OnTouchListener { view, event -> val params = overlayParams ?: return@OnTouchListener false when (event.action) { MotionEvent.ACTION_DOWN -> { startY = event.rawY initialOffset = params.y dragged = false true } MotionEvent.ACTION_MOVE -> { val offset = (initialOffset + event.rawY - startY).toInt() if (kotlin.math.abs(event.rawY - startY) > dp(6)) { dragged = true } params.y = offset windowManager.updateViewLayout(view, params) true } MotionEvent.ACTION_UP -> { if (!dragged) { view.performClick() } true } else -> false } } } private fun panelHeight(): Int { val metrics = resources.displayMetrics return (metrics.heightPixels * 0.62f).toInt().coerceAtLeast(dp(220)) } private fun panelWidth(): Int { val metrics = resources.displayMetrics return dp(320).coerceAtMost((metrics.widthPixels * 0.88f).toInt()) } private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() private fun roundedBackground(color: Int, radiusDp: Float) = GradientDrawable().apply { setColor(color) cornerRadius = radiusDp * resources.displayMetrics.density } private fun buildNotification(): Notification { val stopIntent = PendingIntent.getService( this, 0, Intent(this, EdgeNotesOverlayService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Notification.Builder(this, NOTIFICATION_CHANNEL) } else { @Suppress("DEPRECATION") Notification.Builder(this) } return builder .setSmallIcon(android.R.drawable.ic_menu_info_details) .setContentTitle("Noteable edge list is active") .setContentText("Tap the edge handle to view recent notes.") .setOngoing(true) .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopIntent) .build() } private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( NOTIFICATION_CHANNEL, "Floating notes list", NotificationManager.IMPORTANCE_LOW ) getSystemService(NotificationManager::class.java) .createNotificationChannel(channel) } } companion object { const val ACTION_START = "com.anas4tech.noteable.action.START_EDGE_OVERLAY" const val ACTION_REFRESH = "com.anas4tech.noteable.action.REFRESH_EDGE_OVERLAY" const val ACTION_STOP = "com.anas4tech.noteable.action.STOP_EDGE_OVERLAY" private const val NOTIFICATION_CHANNEL = "noteable_edge_overlay" private const val OVERLAY_NOTIFICATION_ID = 7021 private const val PREFERENCES = "HomeWidgetPreferences" private const val MAX_NOTES = 10 private const val MAX_CHECKLIST_ITEMS = 4 private data class OverlayNote( val id: Int, val title: String, val body: String, val checklist: List ) private data class OverlayCheckItem( val id: Int, val content: String, val completed: Boolean ) fun isRunning(context: Context): Boolean { val manager = context.getSystemService(ACTIVITY_SERVICE) as ActivityManager return manager.getRunningServices(Int.MAX_VALUE).any { it.service.className == EdgeNotesOverlayService::class.java.name } } } }