Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b630a230c8 | ||
|
|
3b14977368 | ||
|
|
d584a1a9a5 | ||
|
|
baffa9a188 | ||
|
|
d3af7d5b7c |
No files matched your search
+3
-1
@@ -47,7 +47,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
|||||||
default sound instead.
|
default sound instead.
|
||||||
- A ring screen over the lock screen with snooze and dismiss. The dismiss
|
- A ring screen over the lock screen with snooze and dismiss. The dismiss
|
||||||
challenge always gives you a way out, works with a screen reader, and Back
|
challenge always gives you a way out, works with a screen reader, and Back
|
||||||
can't silence an alarm.
|
can't silence an alarm. It also opens when the alarm or a timer goes off
|
||||||
|
while you're using Clockula, and with "Ring screen over other apps" turned
|
||||||
|
on it opens over any app instead of a notification.
|
||||||
- Choose what the volume buttons do while an alarm rings: change the volume
|
- Choose what the volume buttons do while an alarm rings: change the volume
|
||||||
(as before), snooze, dismiss or nothing. Dismiss still respects the dismiss
|
(as before), snooze, dismiss or nothing. Dismiss still respects the dismiss
|
||||||
challenge by snoozing instead, and a snooze press with no snoozes left
|
challenge by snoozing instead, and a snooze press with no snoozes left
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ android {
|
|||||||
// versionCode is derived from versionName by scripts/version_info.sh
|
// versionCode is derived from versionName by scripts/version_info.sh
|
||||||
// (1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099), and CI fails if the
|
// (1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099), and CI fails if the
|
||||||
// committed one doesn't match. See docs/RELEASING.md.
|
// committed one doesn't match. See docs/RELEASING.md.
|
||||||
versionCode = 1000004
|
versionCode = 1000005
|
||||||
versionName = "1.0.0-beta.4"
|
versionName = "1.0.0-beta.5"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@
|
|||||||
heads-up notification that still sounds — never to silence. -->
|
heads-up notification that still sounds — never to silence. -->
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
|
||||||
|
<!-- The opt-in "Ring screen over other apps": while it is granted, a ring
|
||||||
|
screen opens over the app in use instead of a heads-up. Not granted
|
||||||
|
at install; turning the setting on sends the user to grant it. -->
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
|
||||||
<!-- The ring notification. Denied, the alarm still sounds: the audio belongs
|
<!-- The ring notification. Denied, the alarm still sounds: the audio belongs
|
||||||
to the foreground service, not to the notification. -->
|
to the foreground service, not to the notification. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
|
|||||||
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||||
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
|
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
|
||||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
|
||||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||||
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
|
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||||
@@ -69,6 +70,7 @@ class ClockulaApp : Application() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ForegroundRingScreen.track(this)
|
||||||
RingNotifications.createChannels(this)
|
RingNotifications.createChannels(this)
|
||||||
UpcomingNotifications.createChannel(this)
|
UpcomingNotifications.createChannel(this)
|
||||||
TimerNotifications.createChannel(this)
|
TimerNotifications.createChannel(this)
|
||||||
|
|||||||
@@ -14,17 +14,21 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
|
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||||
|
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy
|
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||||
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
|
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
|
||||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||||
|
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
|
||||||
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
|
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
|
||||||
import de.jeanlucmakiola.clockula.ring.RingVibrator
|
import de.jeanlucmakiola.clockula.ring.RingVibrator
|
||||||
|
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -46,6 +50,9 @@ class AlarmRingService : Service() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var capabilities: AlarmCapabilities
|
lateinit var capabilities: AlarmCapabilities
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: SettingsPrefs
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
private val audio by lazy { RingAudioPlayer(this) }
|
private val audio by lazy { RingAudioPlayer(this) }
|
||||||
@@ -54,6 +61,9 @@ class AlarmRingService : Service() {
|
|||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var ringing: Job? = null
|
private var ringing: Job? = null
|
||||||
|
|
||||||
|
/** The alarm this service last asked a ring screen for. */
|
||||||
|
private var screenFor: Long? = null
|
||||||
|
|
||||||
/** The notification last posted, put back as it was when the user swipes it away. */
|
/** The notification last posted, put back as it was when the user swipes it away. */
|
||||||
private var posted: Notification? = null
|
private var posted: Notification? = null
|
||||||
|
|
||||||
@@ -93,6 +103,7 @@ class AlarmRingService : Service() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
ring(session)
|
ring(session)
|
||||||
|
openScreen(session)
|
||||||
}
|
}
|
||||||
// Re-delivered rather than dropped: a killed ring is resumed with the
|
// Re-delivered rather than dropped: a killed ring is resumed with the
|
||||||
// same intent, and the session is re-read from storage anyway.
|
// same intent, and the session is re-read from storage anyway.
|
||||||
@@ -123,6 +134,23 @@ class AlarmRingService : Service() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the full-screen intent would only show a heads-up. The screen
|
||||||
|
* reads its session once, so one already up is left alone only when it
|
||||||
|
* is this alarm's: a restart must not reset a half-done challenge, but
|
||||||
|
* an alarm that took over needs a screen of its own.
|
||||||
|
*/
|
||||||
|
private suspend fun openScreen(session: RingSession) {
|
||||||
|
val screen = AlarmRingActivity::class.java
|
||||||
|
ForegroundRingScreen.launch(
|
||||||
|
context = this,
|
||||||
|
screen = screen,
|
||||||
|
screenShowing = screenFor == session.alarm.id && ForegroundRingScreen.isShowing(screen),
|
||||||
|
overOtherApps = settings.ringOverOtherApps.first(),
|
||||||
|
)
|
||||||
|
screenFor = session.alarm.id
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The placeholder the service goes foreground with immediately; [ring]
|
* The placeholder the service goes foreground with immediately; [ring]
|
||||||
* replaces it with the alarm's own notification as soon as the session is
|
* replaces it with the alarm's own notification as soon as the session is
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.clockula.domain.Alarm
|
|||||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentation
|
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentation
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||||
|
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
|
||||||
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@@ -190,9 +191,7 @@ object RingNotifications {
|
|||||||
private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
|
private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
AlarmIntents.REQUEST_SHOW,
|
AlarmIntents.REQUEST_SHOW,
|
||||||
Intent(context, AlarmRingActivity::class.java).apply {
|
ForegroundRingScreen.intent(context, AlarmRingActivity::class.java),
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ object ClockPrefs {
|
|||||||
*/
|
*/
|
||||||
val worldClockSeconds: Pref<Boolean> = booleanPref("world_clock_show_seconds", false)
|
val worldClockSeconds: Pref<Boolean> = booleanPref("world_clock_show_seconds", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a ringing alarm or timer opens its screen over another app
|
||||||
|
* rather than as a heads-up. Off by default, and only in effect while the
|
||||||
|
* "display over other apps" permission is granted. Stored only once that
|
||||||
|
* permission is, and cleared when the settings screen finds it gone, so a
|
||||||
|
* restore that brings the value back without the permission cannot leave
|
||||||
|
* the feature waiting to switch itself on. Not in Clockula's own backup
|
||||||
|
* file, since the permission does not travel with it.
|
||||||
|
*/
|
||||||
|
val ringOverOtherApps: Pref<Boolean> = booleanPref("ring_screen_over_other_apps", false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The World clock tab's temporary local-time row while the device is in a
|
* The World clock tab's temporary local-time row while the device is in a
|
||||||
* zone whose time differs from home (#24). On by default, as Google Clock's
|
* zone whose time differs from home (#24). On by default, as Google Clock's
|
||||||
|
|||||||
@@ -191,6 +191,12 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
|||||||
|
|
||||||
suspend fun setWorldClockSeconds(enabled: Boolean) = store.set(ClockPrefs.worldClockSeconds, enabled)
|
suspend fun setWorldClockSeconds(enabled: Boolean) = store.set(ClockPrefs.worldClockSeconds, enabled)
|
||||||
|
|
||||||
|
// --- ring screen over other apps ---
|
||||||
|
|
||||||
|
val ringOverOtherApps: Flow<Boolean> = store.flow(ClockPrefs.ringOverOtherApps)
|
||||||
|
|
||||||
|
suspend fun setRingOverOtherApps(enabled: Boolean) = store.set(ClockPrefs.ringOverOtherApps, enabled)
|
||||||
|
|
||||||
// --- timer presets (M6) ---
|
// --- timer presets (M6) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,6 +27,37 @@ object RingPresentationPolicy {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What decides whether a ring service opens its screen itself rather than
|
||||||
|
* leaving it to the full-screen intent. All read at the moment of the ring.
|
||||||
|
*/
|
||||||
|
data class DirectLaunchFacts(
|
||||||
|
/** Display on and unlocked: the one state where a full-screen intent is only a heads-up. */
|
||||||
|
val phoneInUse: Boolean,
|
||||||
|
/** One of Clockula's own activities is started, which lets it start another. */
|
||||||
|
val appVisible: Boolean,
|
||||||
|
/** The ring screen for this very ring is already up. */
|
||||||
|
val screenShowing: Boolean,
|
||||||
|
/** The user's "Ring screen over other apps". */
|
||||||
|
val overOtherApps: Boolean,
|
||||||
|
/** "Display over other apps", without which [overOtherApps] can do nothing. */
|
||||||
|
val canDrawOverlays: Boolean,
|
||||||
|
/** A call is in progress, whose screen a ring must not cover. */
|
||||||
|
val inCall: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
object DirectLaunchPolicy {
|
||||||
|
/**
|
||||||
|
* A locked or dark phone is the full-screen intent's, so the screen never
|
||||||
|
* opens twice; an open screen is left alone, so a restart never resets it.
|
||||||
|
* Over another app only when the user asked for it, the permission is
|
||||||
|
* there, and no call is going on.
|
||||||
|
*/
|
||||||
|
fun opensDirectly(facts: DirectLaunchFacts): Boolean = with(facts) {
|
||||||
|
phoneInUse && !screenShowing && (appVisible || (overOtherApps && canDrawOverlays && !inCall))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum class ScheduleMode { EXACT_ALARM_CLOCK, INEXACT_FALLBACK }
|
enum class ScheduleMode { EXACT_ALARM_CLOCK, INEXACT_FALLBACK }
|
||||||
|
|
||||||
object SchedulingPolicy {
|
object SchedulingPolicy {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.ring
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.DirectLaunchFacts
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.DirectLaunchPolicy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a ring screen directly where a full-screen intent would only show a
|
||||||
|
* heads-up.
|
||||||
|
*
|
||||||
|
* A full-screen intent opens its activity only when the phone is locked or the
|
||||||
|
* display is off. On a phone in use SystemUI shows it as a heads-up, so the
|
||||||
|
* ring screen never comes up, not even while the user is inside Clockula.
|
||||||
|
* Android lets an app start activities from the background while it has a
|
||||||
|
* visible window, or while it holds "display over other apps". When either
|
||||||
|
* applies is [DirectLaunchPolicy]'s to say; this object only reads the facts.
|
||||||
|
*/
|
||||||
|
object ForegroundRingScreen {
|
||||||
|
|
||||||
|
/** Every activity of this process between its onStart and its onStop. */
|
||||||
|
private val started = mutableListOf<Activity>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered once from the Application, so the started set is known
|
||||||
|
* before anything can ring. A foreground service does not count: the ring
|
||||||
|
* services are one at the moment they ask.
|
||||||
|
*/
|
||||||
|
fun track(app: Application) {
|
||||||
|
app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
|
||||||
|
override fun onActivityStarted(activity: Activity) {
|
||||||
|
started += activity
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityStopped(activity: Activity) {
|
||||||
|
started -= activity
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||||
|
override fun onActivityResumed(activity: Activity) = Unit
|
||||||
|
override fun onActivityPaused(activity: Activity) = Unit
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||||
|
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a [screen] is up and not on its way out. */
|
||||||
|
fun isShowing(screen: Class<out Activity>): Boolean =
|
||||||
|
started.any { it.javaClass == screen && !it.isFinishing }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens [screen] if the policy says so. [screenShowing] is the caller's:
|
||||||
|
* only it knows whether an open screen belongs to this very ring.
|
||||||
|
*/
|
||||||
|
fun launch(context: Context, screen: Class<out Activity>, screenShowing: Boolean, overOtherApps: Boolean) {
|
||||||
|
val facts = DirectLaunchFacts(
|
||||||
|
phoneInUse = phoneInUse(context),
|
||||||
|
appVisible = started.isNotEmpty(),
|
||||||
|
screenShowing = screenShowing,
|
||||||
|
overOtherApps = overOtherApps,
|
||||||
|
canDrawOverlays = Settings.canDrawOverlays(context),
|
||||||
|
inCall = inCall(context),
|
||||||
|
)
|
||||||
|
if (!DirectLaunchPolicy.opensDirectly(facts)) return
|
||||||
|
// A refused start costs nothing: the notification is up either way.
|
||||||
|
runCatching { context.startActivity(intent(context, screen)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one way a ring screen is opened, directly or by a full-screen intent. */
|
||||||
|
fun intent(context: Context, screen: Class<out Activity>): Intent =
|
||||||
|
Intent(context, screen).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
|
|
||||||
|
private fun phoneInUse(context: Context): Boolean =
|
||||||
|
context.getSystemService(PowerManager::class.java).isInteractive &&
|
||||||
|
!context.getSystemService(KeyguardManager::class.java).isKeyguardLocked
|
||||||
|
|
||||||
|
/** The audio mode, which needs no phone permission, unlike the telecom APIs. */
|
||||||
|
private fun inCall(context: Context): Boolean =
|
||||||
|
when (context.getSystemService(AudioManager::class.java).mode) {
|
||||||
|
AudioManager.MODE_IN_CALL, AudioManager.MODE_IN_COMMUNICATION, AudioManager.MODE_RINGTONE -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.clockula.domain.format.ClockFormat
|
|||||||
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
||||||
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction
|
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction
|
||||||
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
|
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
|
||||||
|
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
||||||
import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver
|
import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver
|
||||||
import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity
|
import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity
|
||||||
@@ -203,8 +204,7 @@ object TimerNotifications {
|
|||||||
private fun ringPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
|
private fun ringPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
TimerIntents.REQUEST_RING,
|
TimerIntents.REQUEST_RING,
|
||||||
Intent(context, TimerRingActivity::class.java)
|
ForegroundRingScreen.intent(context, TimerRingActivity::class.java),
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
|
|
||||||
PENDING_INTENT_FLAGS,
|
PENDING_INTENT_FLAGS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
|||||||
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
|
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
|
||||||
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
|
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
|
||||||
import de.jeanlucmakiola.clockula.domain.timer.TimerSettings
|
import de.jeanlucmakiola.clockula.domain.timer.TimerSettings
|
||||||
|
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
|
||||||
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
|
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
|
||||||
import de.jeanlucmakiola.clockula.ring.RingVibrator
|
import de.jeanlucmakiola.clockula.ring.RingVibrator
|
||||||
import de.jeanlucmakiola.clockula.timer.AlarmRingStatus
|
import de.jeanlucmakiola.clockula.timer.AlarmRingStatus
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerServiceState
|
import de.jeanlucmakiola.clockula.timer.TimerServiceState
|
||||||
|
import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -35,6 +37,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
@@ -272,7 +275,7 @@ class TimerService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun post(state: TimerNotificationState, sounding: Boolean) {
|
private suspend fun post(state: TimerNotificationState, sounding: Boolean) {
|
||||||
var fullScreen = false
|
var fullScreen = false
|
||||||
if (!state.alertOnce) {
|
if (!state.alertOnce) {
|
||||||
alreadyAlerted = true
|
alreadyAlerted = true
|
||||||
@@ -286,6 +289,17 @@ class TimerService : Service() {
|
|||||||
fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent
|
fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent
|
||||||
}
|
}
|
||||||
startForeground(TimerNotifications.timer(this, state, fullScreen))
|
startForeground(TimerNotifications.timer(this, state, fullScreen))
|
||||||
|
// Where the full-screen intent would only show a heads-up.
|
||||||
|
if (!state.alertOnce && sounding) {
|
||||||
|
// An open timer screen follows every expired timer by itself.
|
||||||
|
val screen = TimerRingActivity::class.java
|
||||||
|
ForegroundRingScreen.launch(
|
||||||
|
context = this,
|
||||||
|
screen = screen,
|
||||||
|
screenShowing = ForegroundRingScreen.isShowing(screen),
|
||||||
|
overOtherApps = settings.ringOverOtherApps.first(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
package de.jeanlucmakiola.clockula.ui.settings
|
package de.jeanlucmakiola.clockula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import de.jeanlucmakiola.clockula.R
|
import de.jeanlucmakiola.clockula.R
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||||
import de.jeanlucmakiola.clockula.ui.alarms.DismissChallengePicker
|
import de.jeanlucmakiola.clockula.ui.alarms.DismissChallengePicker
|
||||||
@@ -32,7 +40,8 @@ private enum class OpenAlarmPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* What every new alarm starts with: how it sounds, how it snoozes, and how it
|
* What every new alarm starts with: how it sounds, how it snoozes, and how it
|
||||||
* is dismissed; then how long any alarm rings, which no alarm overrides (#17).
|
* is dismissed; then how long any alarm rings, which no alarm overrides (#17),
|
||||||
|
* and whether a ring screen may open over another app.
|
||||||
* The ringtone picker itself is ViewModel state, drawn by the hub.
|
* The ringtone picker itself is ViewModel state, drawn by the hub.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -107,6 +116,8 @@ internal fun AlarmDefaultsScreen(
|
|||||||
onClick = { openPicker = OpenAlarmPicker.VOLUME_BUTTONS },
|
onClick = { openPicker = OpenAlarmPicker.VOLUME_BUTTONS },
|
||||||
)
|
)
|
||||||
SettingsHint(stringResource(R.string.settings_volume_buttons_hint))
|
SettingsHint(stringResource(R.string.settings_volume_buttons_hint))
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
RingOverOtherAppsRow(state.ringOverOtherApps, viewModel::onRingOverOtherAppsChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (openPicker) {
|
when (openPicker) {
|
||||||
@@ -171,3 +182,58 @@ private fun volumeButtonsText(action: VolumeButtonAction): String = stringResour
|
|||||||
VolumeButtonAction.NOTHING -> R.string.volume_buttons_nothing
|
VolumeButtonAction.NOTHING -> R.string.volume_buttons_nothing
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On exactly while the choice is stored, and the choice is stored only while
|
||||||
|
* "display over other apps" is granted. Turning it on without the permission
|
||||||
|
* opens the system page that grants it and stores the choice on return if
|
||||||
|
* the user granted it there. A permission found missing (revoked, or a
|
||||||
|
* restore that brought the value back without it) clears the choice, so it
|
||||||
|
* can never come back on by itself.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RingOverOtherAppsRow(stored: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var awaitingGrant by rememberSaveable { mutableStateOf(false) }
|
||||||
|
// Keyed on nothing, so it runs on each return to the screen and never
|
||||||
|
// because a tap just set [awaitingGrant]; the latest values are read live.
|
||||||
|
val currentStored by rememberUpdatedState(stored)
|
||||||
|
val currentOnChange by rememberUpdatedState(onChange)
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
val permitted = Settings.canDrawOverlays(context)
|
||||||
|
if (awaitingGrant) {
|
||||||
|
awaitingGrant = false
|
||||||
|
if (permitted) currentOnChange(true)
|
||||||
|
} else if (currentStored && !permitted) {
|
||||||
|
currentOnChange(false)
|
||||||
|
}
|
||||||
|
onPauseOrDispose {}
|
||||||
|
}
|
||||||
|
val toggle = { enabled: Boolean ->
|
||||||
|
when {
|
||||||
|
!enabled -> onChange(false)
|
||||||
|
Settings.canDrawOverlays(context) -> onChange(true)
|
||||||
|
else -> awaitingGrant = openOverlaySettings(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_ring_over_other_apps),
|
||||||
|
summary = stringResource(R.string.settings_ring_over_other_apps_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = { Switch(checked = stored, onCheckedChange = { toggle(it) }) },
|
||||||
|
onClick = { toggle(!stored) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permission's own page for Clockula, else the app's details page, where
|
||||||
|
* some builds that lack the first keep the same switch. False when neither
|
||||||
|
* opens, so the choice is never left waiting on a page that never showed.
|
||||||
|
*/
|
||||||
|
private fun openOverlaySettings(context: Context): Boolean {
|
||||||
|
val pkg = Uri.parse("package:${context.packageName}")
|
||||||
|
return listOf(
|
||||||
|
Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, pkg),
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, pkg),
|
||||||
|
).any { runCatching { context.startActivity(it) }.isSuccess }
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ internal sealed interface SettingsUiState {
|
|||||||
val selfCheckProblemCount: Int? = null,
|
val selfCheckProblemCount: Int? = null,
|
||||||
/** The world clock's "Display time with seconds" (#23); not a clock default. */
|
/** The world clock's "Display time with seconds" (#23); not a clock default. */
|
||||||
val worldClockSeconds: Boolean,
|
val worldClockSeconds: Boolean,
|
||||||
|
/** The stored choice; the screen pairs it with the overlay permission. */
|
||||||
|
val ringOverOtherApps: Boolean,
|
||||||
) : SettingsUiState
|
) : SettingsUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,9 @@ class SettingsViewModel @Inject internal constructor(
|
|||||||
settings.defaults,
|
settings.defaults,
|
||||||
selfCheckProblemCount,
|
selfCheckProblemCount,
|
||||||
settings.worldClockSeconds,
|
settings.worldClockSeconds,
|
||||||
) { appearance, defaults, problemCount, worldClockSeconds ->
|
settings.ringOverOtherApps,
|
||||||
SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds)
|
) { appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps ->
|
||||||
|
SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -253,6 +254,9 @@ class SettingsViewModel @Inject internal constructor(
|
|||||||
fun onWorldClockSecondsChanged(enabled: Boolean) =
|
fun onWorldClockSecondsChanged(enabled: Boolean) =
|
||||||
viewModelScope.launch { settings.setWorldClockSeconds(enabled) }
|
viewModelScope.launch { settings.setWorldClockSeconds(enabled) }
|
||||||
|
|
||||||
|
fun onRingOverOtherAppsChanged(enabled: Boolean) =
|
||||||
|
viewModelScope.launch { settings.setRingOverOtherApps(enabled) }
|
||||||
|
|
||||||
// --- the home time-zone picker ---
|
// --- the home time-zone picker ---
|
||||||
|
|
||||||
fun onOpenHomeZonePicker() {
|
fun onOpenHomeZonePicker() {
|
||||||
|
|||||||
@@ -344,6 +344,8 @@
|
|||||||
<!-- #18: what the volume buttons do while an alarm rings -->
|
<!-- #18: what the volume buttons do while an alarm rings -->
|
||||||
<string name="settings_volume_buttons">Volume buttons while ringing</string>
|
<string name="settings_volume_buttons">Volume buttons while ringing</string>
|
||||||
<string name="settings_volume_buttons_hint">Applies to every alarm while its ring screen is showing. With a dismiss challenge set, Dismiss snoozes instead, so the challenge still has to be done. When a snooze is called for but none are left, the buttons change the volume instead and the alarm keeps ringing.</string>
|
<string name="settings_volume_buttons_hint">Applies to every alarm while its ring screen is showing. With a dismiss challenge set, Dismiss snoozes instead, so the challenge still has to be done. When a snooze is called for but none are left, the buttons change the volume instead and the alarm keeps ringing.</string>
|
||||||
|
<string name="settings_ring_over_other_apps">Ring screen over other apps</string>
|
||||||
|
<string name="settings_ring_over_other_apps_hint">When an alarm or timer goes off while you\'re using another app, show its ring screen instead of a notification. Needs permission to display over other apps.</string>
|
||||||
<string name="volume_buttons_control_volume">Change the volume</string>
|
<string name="volume_buttons_control_volume">Change the volume</string>
|
||||||
<string name="volume_buttons_snooze">Snooze</string>
|
<string name="volume_buttons_snooze">Snooze</string>
|
||||||
<string name="volume_buttons_dismiss">Dismiss</string>
|
<string name="volume_buttons_dismiss">Dismiss</string>
|
||||||
|
|||||||
@@ -285,6 +285,9 @@ class ManifestRulesTest {
|
|||||||
"android.permission.SCHEDULE_EXACT_ALARM",
|
"android.permission.SCHEDULE_EXACT_ALARM",
|
||||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||||
"android.permission.USE_FULL_SCREEN_INTENT",
|
"android.permission.USE_FULL_SCREEN_INTENT",
|
||||||
|
// The opt-in ring screen over other apps. Granted by hand, and
|
||||||
|
// only after the user turns the setting on.
|
||||||
|
"android.permission.SYSTEM_ALERT_WINDOW",
|
||||||
"android.permission.POST_NOTIFICATIONS",
|
"android.permission.POST_NOTIFICATIONS",
|
||||||
// Already present for the timer's Live Update chip; not added by
|
// Already present for the timer's Live Update chip; not added by
|
||||||
// M10, which — per docs/PLAN.md's amended §1 #2 — adds no permission
|
// M10, which — per docs/PLAN.md's amended §1 #2 — adds no permission
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.domain.alarm
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class DirectLaunchPolicyTest {
|
||||||
|
|
||||||
|
private fun facts(
|
||||||
|
phoneInUse: Boolean = true,
|
||||||
|
appVisible: Boolean = false,
|
||||||
|
screenShowing: Boolean = false,
|
||||||
|
overOtherApps: Boolean = false,
|
||||||
|
canDrawOverlays: Boolean = false,
|
||||||
|
inCall: Boolean = false,
|
||||||
|
) = DirectLaunchFacts(phoneInUse, appVisible, screenShowing, overOtherApps, canDrawOverlays, inCall)
|
||||||
|
|
||||||
|
private fun opens(facts: DirectLaunchFacts) = DirectLaunchPolicy.opensDirectly(facts)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inside the app the screen opens directly`() {
|
||||||
|
assertThat(opens(facts(appVisible = true))).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `over another app it stays a heads-up by default`() {
|
||||||
|
assertThat(opens(facts())).isFalse()
|
||||||
|
assertThat(opens(facts(canDrawOverlays = true))).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `over another app the setting needs the permission`() {
|
||||||
|
assertThat(opens(facts(overOtherApps = true))).isFalse()
|
||||||
|
assertThat(opens(facts(overOtherApps = true, canDrawOverlays = true))).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a call is never covered from another app`() {
|
||||||
|
assertThat(opens(facts(overOtherApps = true, canDrawOverlays = true, inCall = true))).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a locked or dark phone is left to the full-screen intent`() {
|
||||||
|
assertThat(opens(facts(phoneInUse = false, appVisible = true))).isFalse()
|
||||||
|
assertThat(opens(facts(phoneInUse = false, overOtherApps = true, canDrawOverlays = true))).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a screen already showing this ring is not reopened`() {
|
||||||
|
assertThat(opens(facts(appVisible = true, screenShowing = true))).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-6
@@ -996,13 +996,17 @@ declared because a specific path would otherwise fail, silently, at 07:00.
|
|||||||
| `WAKE_LOCK` | keep the CPU up for the length of a ring |
|
| `WAKE_LOCK` | keep the CPU up for the length of a ring |
|
||||||
| `VIBRATE` | an alarm and a timer both vibrate, and vibrate alone when no audio source opens |
|
| `VIBRATE` | an alarm and a timer both vibrate, and vibrate alone when no audio source opens |
|
||||||
| `ACCESS_NOTIFICATION_POLICY` | issue #1's opt-in bedtime mode. Install-time, and on its own it does nothing: it lists Clockula under Special app access › Do Not Disturb, which the user grants by hand. With that grant Clockula adds one Do Not Disturb rule of its own, which always lets alarms through (§18) |
|
| `ACCESS_NOTIFICATION_POLICY` | issue #1's opt-in bedtime mode. Install-time, and on its own it does nothing: it lists Clockula under Special app access › Do Not Disturb, which the user grants by hand. With that grant Clockula adds one Do Not Disturb rule of its own, which always lets alarms through (§18) |
|
||||||
|
| `SYSTEM_ALERT_WINDOW` | the opt-in "Ring screen over other apps". Granted by hand from the system page the setting opens. It is used only as Android's background-activity-start exemption, so a ring screen opens over the app in use where a full-screen intent would be a heads-up. Clockula draws no overlay window |
|
||||||
|
|
||||||
**M6 adds no permission at all.** A timer never takes over the screen (no
|
**M6 adds no permission at all.** M6 shipped timers without a screen of their
|
||||||
full-screen intent, no ring activity, no `showWhenLocked`), because its user set
|
own, on the reasoning that their user set them minutes ago and is in the room.
|
||||||
it minutes ago and is in the room, so the heads-up notification, the audio, the
|
Issue #15 reversed that: a timer finishing on a locked phone rang behind a black
|
||||||
pill's expired state and the tab's expired row are four ways to notice and none
|
screen. An expired, sounding timer now opens `TimerRingActivity` over the lock
|
||||||
of them seizes the device. The foreground-service, wake-lock, vibrate and
|
screen through a full-screen intent, as an alarm does, and both ring services
|
||||||
notification set above already covers the rest.
|
open their screen directly where that intent would only be a heads-up: inside
|
||||||
|
Clockula always, and over another app with the opt-in setting
|
||||||
|
(`DirectLaunchPolicy`). The foreground-service, wake-lock, vibrate and
|
||||||
|
notification set above covers the rest.
|
||||||
|
|
||||||
Neither of the two revocable ones can silence an alarm. A denied
|
Neither of the two revocable ones can silence an alarm. A denied
|
||||||
`POST_NOTIFICATIONS` costs the user the notification and nothing else, because the
|
`POST_NOTIFICATIONS` costs the user the notification and nothing else, because the
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ on. Clockula only opens the address and sends no data of yours with it.
|
|||||||
Clockula Do Not Disturb access yourself. Clockula then adds one Do Not
|
Clockula Do Not Disturb access yourself. Clockula then adds one Do Not
|
||||||
Disturb mode of its own and turns it on and off on your schedule. It does not
|
Disturb mode of its own and turns it on and off on your schedule. It does not
|
||||||
read your notifications.
|
read your notifications.
|
||||||
|
- `SYSTEM_ALERT_WINDOW`: only used if you turn on "Ring screen over other apps"
|
||||||
|
and grant it yourself. A ringing alarm or timer then opens its ring screen
|
||||||
|
over the app you're using. Clockula draws nothing else over other apps.
|
||||||
- `ACCESS_NETWORK_STATE`: added by the Android library that updates the
|
- `ACCESS_NETWORK_STATE`: added by the Android library that updates the
|
||||||
home-screen widgets (WorkManager). It lets an app see whether a network
|
home-screen widgets (WorkManager). It lets an app see whether a network
|
||||||
connection exists. It does not let it use one, and without `INTERNET` no
|
connection exists. It does not let it use one, and without `INTERNET` no
|
||||||
|
|||||||
Reference in new issue
Block a user