Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ android {
defaultConfig {
applicationId "com.github.muellerma.coffee"
minSdkVersion 21
targetSdkVersion 34
versionCode 45
versionName "2.25"
targetSdkVersion 36
versionCode 46
versionName "2.26"
}
namespace = "com.github.muellerma.coffee"

Expand Down Expand Up @@ -55,7 +55,7 @@ aboutLibraries {

dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "androidx.core:core-ktx:1.16.0"
implementation "androidx.core:core-ktx:1.17.0"
implementation "androidx.appcompat:appcompat:1.7.1"
implementation "androidx.fragment:fragment-ktx:1.8.8"
implementation "com.google.android.material:material:1.12.0"
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />

<application
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@ class ForegroundService : Service(), ServiceStatusObserver {
}

private fun getRunningNotification(prefs: Prefs): Notification? {
// Check if we should use Android 16+ progress-centric notifications
if (Build.VERSION.SDK_INT >= 36
&& prefs.useProgressNotifications
&& ProgressNotificationManager.isAvailable()) {
return getProgressNotification(prefs)
}

// Fall back to classic notification style
val stopIntent = Intent(this, ForegroundService::class.java).apply {
action = ACTION_STOP
}
Expand Down Expand Up @@ -217,6 +225,22 @@ class ForegroundService : Service(), ServiceStatusObserver {
.build()
}

@androidx.annotation.RequiresApi(36)
private fun getProgressNotification(prefs: Prefs): Notification? {
val status = coffeeApp().lastStatusUpdate
if (status is ServiceStatus.Stopped) {
return null
}

val remaining = (status as? ServiceStatus.Running)?.remaining
val progressManager = ProgressNotificationManager(this)
return progressManager.createProgressNotification(
remaining = remaining,
timeout = prefs.timeout,
prefs = prefs
)
}

private fun getTimeoutAction(): NotificationCompat.Action {
Log.d(TAG, "getTimeoutAction()")
val intent = Intent(this, ForegroundService::class.java).apply {
Expand Down Expand Up @@ -305,7 +329,7 @@ class ForegroundService : Service(), ServiceStatusObserver {
private val TAG = ForegroundService::class.java.simpleName
private const val ACTION_STOP = "stop_action"
const val ACTION_CHANGE_PREF_TIMEOUT = "change_pref_timeout"
private const val ACTION_CHANGE_PREF_ALLOW_DIMMING = "change_pref_dimming"
const val ACTION_CHANGE_PREF_ALLOW_DIMMING = "change_pref_dimming"
const val NOTIFICATION_ID = 1
const val NOTIFICATION_CHANNEL_ID = "foreground_service"

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/github/muellerma/coffee/Prefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,7 @@ class Prefs(private val context: Context) {
var alternateModeOldTimeout: Int
get() = sharedPrefs.getInt("alternate_mode_old_timeout", -1)
set(value) = sharedPrefs.edit { putInt("alternate_mode_old_timeout", value) }

val useProgressNotifications: Boolean
get() = sharedPrefs.getBoolean("use_progress_notifications", true)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package com.github.muellerma.coffee

import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import kotlin.time.Duration

// Android 16 API level constant (Baklava)
private const val ANDROID_16_BAKLAVA = 36

/**
* Manages Android 16+ Progress-centric notifications for Coffee app.
* This creates live activity-style notifications showing wake-lock status and timer progress.
*/
@RequiresApi(ANDROID_16_BAKLAVA)
class ProgressNotificationManager(private val context: Context) {

/**
* Creates an Android 16 progress-centric notification showing Coffee's wake-lock status
*/
fun createProgressNotification(
remaining: Duration?,
timeout: Int,
prefs: Prefs
): Notification {
Log.d(TAG, "Creating Android 16 progress-centric notification")

val stopIntent = Intent(context, ForegroundService::class.java).apply {
action = "stop_action"
}

val stopPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(
context,
0,
stopIntent,
PendingIntent.FLAG_IMMUTABLE
)
} else {
PendingIntent.getService(
context,
0,
stopIntent,
PendingIntent.FLAG_IMMUTABLE
)
}

// Build the progress style notification
val progressStyle = NotificationCompat.ProgressStyle()

// Define the journey for the wake-lock session
when {
remaining == null -> {
// No timeout - indeterminate progress
progressStyle.setProgressIndeterminate(true)
// Add a single segment for visual styling
progressStyle.setProgressSegments(
listOf(
NotificationCompat.ProgressStyle.Segment(100)
.setColor(ContextCompat.getColor(context, R.color.coffee_brown))
)
)
}
else -> {
// With timeout - show progress through the timer
val totalSeconds = timeout * 60
val elapsedSeconds = (totalSeconds - remaining.inWholeSeconds.toInt()).coerceAtLeast(0)

// Add progress segment
progressStyle.setProgressSegments(
listOf(
NotificationCompat.ProgressStyle.Segment(totalSeconds)
.setColor(ContextCompat.getColor(context, R.color.coffee_brown))
)
)

// Set current progress
progressStyle.setProgress(elapsedSeconds)
}
}

// Determine notification title based on remaining time
val title = if (remaining == null) {
context.getString(R.string.notification_title_no_timeout)
} else {
context.getString(R.string.notification_title_timeout, remaining.toFormattedTime())
}

// Status chip configuration - shows coffee icon and time remaining
val whenTime = if (remaining != null) {
// Set when time to the end of the countdown (now + remaining time)
System.currentTimeMillis() + remaining.inWholeMilliseconds
} else {
// For no-timeout mode, don't show time in status chip
0L
}

// Short critical text for status chip (7 characters max suggested)
val chipText = if (remaining != null && remaining.inWholeMinutes > 0) {
"${remaining.inWholeMinutes}min" // Shows remaining minutes in status chip
} else {
null // No text for indeterminate mode or when < 1 minute
}

// Build the notification with progress style
val builder = NotificationCompat.Builder(context, ForegroundService.NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_twotone_free_breakfast_24_accent) // Coffee icon appears in status chip
.setContentTitle(title)
.setContentText(context.getString(R.string.tap_to_turn_off))
.setOngoing(true)
.setRequestPromotedOngoing(true) // Request promotion for Live Update - required for status chip
.setShowWhen(remaining != null) // Only show time if we have a timeout
.setWhen(whenTime) // Countdown end time for status chip
.setUsesChronometer(remaining != null) // Use chronometer for countdown timer
.setChronometerCountDown(remaining != null) // Count down to zero
.setShortCriticalText(chipText) // Shows in status chip (e.g., "5min")
.setColor(ContextCompat.getColor(context, R.color.coffee_brown))
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(stopPendingIntent)
.setStyle(progressStyle)

// Add action buttons
addActionButtons(builder, prefs)

return builder.build()
}

private fun addActionButtons(builder: NotificationCompat.Builder, prefs: Prefs) {
// Add timeout change action
val timeoutIntent = Intent(context, ForegroundService::class.java).apply {
action = ForegroundService.ACTION_CHANGE_PREF_TIMEOUT
}
val timeoutPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(
context,
1,
timeoutIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getService(
context,
1,
timeoutIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}

val timeoutAction = NotificationCompat.Action(
R.drawable.ic_baseline_access_time_24,
context.getString(R.string.timeout_next),
timeoutPendingIntent
)

// Add dimming toggle action
val dimmingIntent = Intent(context, ForegroundService::class.java).apply {
action = ForegroundService.ACTION_CHANGE_PREF_ALLOW_DIMMING
}
val dimmingPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(
context,
2,
dimmingIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getService(
context,
2,
dimmingIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}

val dimmingTitle = if (prefs.allowDimming) {
R.string.allow_dimming_disable
} else {
R.string.allow_dimming_enable
}

val dimmingAction = NotificationCompat.Action(
R.drawable.ic_baseline_brightness_medium_24,
context.getString(dimmingTitle),
dimmingPendingIntent
)

builder.addAction(timeoutAction)
builder.addAction(dimmingAction)
}

companion object {
private val TAG = ProgressNotificationManager::class.java.simpleName

/**
* Check if progress-centric notifications are available (Android 16+)
*/
fun isAvailable(): Boolean {
return Build.VERSION.SDK_INT >= ANDROID_16_BAKLAVA
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ class PreferenceActivity : AppCompatActivity() {
return@setOnPreferenceChangeListener true
}

// Hide progress notifications preference on pre-Android 16 devices
val progressNotificationsPref = preferenceManager.findPreference<Preference>("use_progress_notifications")
if (Build.VERSION.SDK_INT < 36) {
progressNotificationsPref?.isVisible = false
}

val aboutPref = getPreference("about")
aboutPref.setOnPreferenceClickListener {
val fragment = LibsBuilder()
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
<string name="alternate_mode_permissions">Alternate mode requires this permission</string>
<string name="alternate_mode_unable_to_set_old_timeout">Coffee couldn\'t set the previous timeout</string>

<!-- Android 16+ Progress Notifications -->
<string name="use_progress_notifications">Use live activity notifications</string>
<string name="use_progress_notifications_summary">Show rich progress notifications on Android 16+</string>

<!-- About menu -->
<string name="about">About Coffee</string>
<string name="error_no_browser_found">No browser found</string>
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/xml/pref_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
android:summary="@string/alternate_mode_summary"
android:defaultValue="false"
android:widgetLayout="@layout/preference_material_switch" />
<SwitchPreferenceCompat
android:key="use_progress_notifications"
android:title="@string/use_progress_notifications"
android:summary="@string/use_progress_notifications_summary"
android:defaultValue="true"
android:widgetLayout="@layout/preference_material_switch" />
</PreferenceCategory>
<PreferenceCategory>
<Preference
Expand Down