-
-
Notifications
You must be signed in to change notification settings - Fork 6.8k
Expand file tree
/
Copy pathAttachmentProgressService.kt
More file actions
211 lines (182 loc) · 6.8 KB
/
Copy pathAttachmentProgressService.kt
File metadata and controls
211 lines (182 loc) · 6.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.service
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.util.PendingIntentFlags
import org.signal.core.util.logging.Log
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.time.Duration.Companion.milliseconds
/**
* A service to show attachment progress. In order to ensure we only show one status notification,
* this handles both compression progress and upload progress.
*
* This class has a bunch of stuff to allow multiple people to "start" this service, but write to a
* single notification. That way if something is compressing while something else is uploading,
* or if there's two things uploading, we just have the one notification.
*
* To do this, it maintains a set of controllers. The service is told when those controllers change,
* and it will only render the oldest controller in the set.
*
* We also use the number of controllers to determine if we actually need to start/stop the actual service.
*/
class AttachmentProgressService : SafeForegroundService() {
companion object {
private val TAG = Log.tag(AttachmentProgressService::class.java)
private var title: String = ""
private var progress: Float = 0f
set(value) {
field = value.coerceIn(0f, 1f)
}
private var indeterminate: Boolean = false
private val listeners: MutableSet<() -> Unit> = CopyOnWriteArraySet()
private val controllers: LinkedHashSet<Controller> = linkedSetOf()
private val controllerLock = ReentrantLock()
/**
* Start the service with the provided [title]. You will receive a controllers that you can
* use to update the notification.
*
* Important: This could fail to start! We do our best to start the service regardless of context,
* but it will fail on some devices. If this happens, the returned [Controller] will be null.
*/
@JvmStatic
fun start(context: Context, title: String): Controller? {
controllerLock.withLock {
val started = if (controllers.isEmpty()) {
Log.i(TAG, "[start] First controller. Starting.")
start(context, AttachmentProgressService::class.java)
} else {
Log.i(TAG, "[start] No need to start the service again. Already have an active controller.")
true
}
return if (started) {
val controller = Controller(context, title)
controllers += controller
onControllersChanged(context)
controller
} else {
null
}
}
}
private fun stop(context: Context, fromTimeout: Boolean = false) {
stop(context, AttachmentProgressService::class.java, fromTimeout)
}
private fun onControllersChanged(context: Context) {
controllerLock.withLock {
if (controllers.isNotEmpty()) {
val originalTitle = title
val originalProgress = progress
val originalIndeterminate = indeterminate
title = controllers.first().title
progress = controllers.first().progress
indeterminate = controllers.first().indeterminate
if (originalTitle != title || originalProgress != progress || originalIndeterminate != indeterminate) {
listeners.forEach { it() }
}
} else {
Log.i(TAG, "[onControllersChanged] No controllers remaining. Stopping.")
stop(context)
}
}
}
}
val listener = {
startForeground(notificationId, getForegroundNotification(Intent()))
}
override val tag: String = TAG
override val notificationId: Int = NotificationIds.ATTACHMENT_PROGRESS
override fun getForegroundNotification(intent: Intent): Notification {
return NotificationCompat.Builder(this, NotificationChannels.getInstance().OTHER)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setStyle(
NotificationCompat.ProgressStyle()
.setProgress((progress * 100).toInt())
.setProgressIndeterminate(indeterminate)
)
.setContentIntent(PendingIntent.getActivity(this, 0, MainActivity.clearTop(this), PendingIntentFlags.mutable()))
.setVibrate(longArrayOf(0))
.build()
}
override fun onCreate() {
super.onCreate()
listeners += listener
}
override fun onDestroy() {
super.onDestroy()
listeners -= listener
}
override fun onTimeout(startId: Int, fgsType: Int) {
Log.w(TAG, "AttachmentProgressService has timed out. Removing all controllers. startId: $startId, foregroundServiceType: $fgsType")
controllerLock.withLock {
controllers.forEach { it.closeFromTimeout() }
stop(context = this, fromTimeout = true)
}
listeners -= listener
}
class Controller(private val context: Context, title: String) : AutoCloseable {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
private val progressFlow = MutableSharedFlow<Float>(replay = 0, extraBufferCapacity = 1)
init {
coroutineScope.launch {
progressFlow
.throttleLatest(500.milliseconds) // avoid OS notification rate limiting
.collectLatest { progress = it }
}
}
var title: String = title
set(value) {
field = value
onControllersChanged(context)
}
var progress: Float = 0f
private set(value) {
field = value
indeterminate = false
onControllersChanged(context)
}
var indeterminate: Boolean = false
private set
/** Has to have separate setter to avoid infinite loops when [progress] and [indeterminate] interact. */
fun setIndeterminate(value: Boolean) {
progress = 0f
indeterminate = value
onControllersChanged(context)
}
fun updateProgress(progress: Float) {
progressFlow.tryEmit(progress)
}
fun closeFromTimeout() {
controllerLock.withLock {
coroutineScope.cancel()
controllers.remove(this)
}
}
override fun close() {
controllerLock.withLock {
coroutineScope.cancel()
controllers.remove(this)
onControllersChanged(context)
}
}
}
}