@@ -20,16 +20,20 @@ import com.addev.listaspam.R
2020import com.google.i18n.phonenumbers.PhoneNumberUtil
2121import kotlinx.coroutines.CoroutineScope
2222import kotlinx.coroutines.Dispatchers
23+ import kotlinx.coroutines.ExperimentalCoroutinesApi
2324import kotlinx.coroutines.channels.Channel
2425import kotlinx.coroutines.coroutineScope
2526import kotlinx.coroutines.launch
2627import kotlinx.coroutines.runBlocking
28+ import kotlinx.coroutines.selects.onTimeout
29+ import kotlinx.coroutines.selects.select
2730import kotlinx.coroutines.withContext
2831import okhttp3.OkHttpClient
2932import okhttp3.Request
3033import org.jsoup.Jsoup
3134import java.io.IOException
3235import java.util.Locale
36+ import java.util.concurrent.atomic.AtomicInteger
3337import java.util.logging.Logger
3438
3539/* *
@@ -132,13 +136,14 @@ class SpamUtils {
132136 callback : (isSpam: Boolean ) -> Unit = {}
133137 ) {
134138 CoroutineScope (Dispatchers .IO ).launch {
135- val number = if (details != null ) getRawPhoneNumber(details) else phoneNumber;
136-
137139 if (! isBlockingEnabled(context)) {
138140 showToast(context, context.getString(R .string.blocking_disabled), Toast .LENGTH_LONG )
141+ callback(false )
139142 return @launch
140143 }
141144
145+ val number = if (details != null ) getRawPhoneNumber(details) else phoneNumber;
146+
142147 val sharedPreferences = context.getSharedPreferences(SPAM_PREFS , Context .MODE_PRIVATE )
143148 val blockedNumbers = sharedPreferences.getStringSet(BLOCK_NUMBERS_KEY , null )
144149
@@ -153,18 +158,21 @@ class SpamUtils {
153158 )
154159 return @launch
155160 } else {
161+ callback(false )
156162 return @launch
157163 }
158164 }
159165
160166 // Check whitelist first - if whitelisted, always allow
161167 if (isNumberWhitelisted(context, number)) {
168+ callback(false )
162169 return @launch
163170 }
164171
165172 // Don't check number if is in contacts
166173 val isNumberInAgenda = isNumberInAgenda(context, number)
167174 if (isNumberInAgenda) {
175+ callback(false )
168176 return @launch
169177 }
170178
@@ -250,42 +258,79 @@ class SpamUtils {
250258 callback
251259 )
252260 } else {
253- handleNonSpamNumber(context, number)
261+ // handleNonSpamNumber(context, number)
262+ callback(false )
254263 return @launch
255264 }
256265 }
257266 }
258267
259268 /* *
260- * Runs a list of suspend functions in parallel to check if a number is spam.
269+ * Performs a "race" among multiple spam checkers to determine if a phone number is spam.
270+ *
271+ * Each checker is a suspend function that returns `true` if the number is spam.
272+ * The function returns `true` as soon as the first checker reports spam.
273+ * If all checkers finish and none report spam, it returns `false`.
274+ * A timeout can be provided to handle long-running or stuck checkers.
261275 *
262- * Launches all checks simultaneously and returns `true` as soon as
263- * any function returns `true`. At that point, it cancels all other running tasks.
264- * If none return `true`, it returns `false`.
276+ * This function launches all checkers concurrently and cancels remaining jobs
277+ * as soon as a result is determined, to save resources.
265278 *
266- * @param spamCheckers List of suspend functions that take a number (String) and return a Boolean indicating spam status.
267- * @param number The number (String) to be evaluated by the spam checkers.
268- * @return `true` if at least one function determines the number is spam; `false` otherwise.
279+ * @param spamCheckers A list of suspend functions that each take a phone number
280+ * and return `true` if it is spam.
281+ * @param number The phone number to check for spam.
282+ * @param timeoutMs Maximum time in milliseconds to wait for a result before returning `false`.
283+ * Default is 5000ms.
284+ *
285+ * @return `true` if any checker reports spam, `false` if none report spam or timeout occurs.
269286 */
287+ @OptIn(ExperimentalCoroutinesApi ::class )
270288 private suspend fun isSpamRace (
271289 spamCheckers : List <suspend (String ) -> Boolean >,
272- number : String
290+ number : String ,
291+ timeoutMs : Long = 5000
273292 ): Boolean = coroutineScope {
274- val resultChannel = Channel <Boolean >()
293+ if (spamCheckers.isEmpty()) return @coroutineScope false
294+
295+ val resultChannel = Channel <Boolean >(capacity = Channel .UNLIMITED )
296+ val remaining = AtomicInteger (spamCheckers.size)
275297
276298 val jobs = spamCheckers.map { checker ->
277299 launch {
300+ val start = System .currentTimeMillis()
278301 val result = runCatching { checker(number) }.getOrDefault(false )
279- if (result) resultChannel.send(true )
302+ val elapsed = System .currentTimeMillis() - start
303+
304+ Logger .getLogger(" SpamUtils" ).info(
305+ " Spam checker for $number completed in ${elapsed} ms, result: $result "
306+ )
307+
308+ if (result) {
309+ resultChannel.send(true )
310+ } else if (remaining.decrementAndGet() == 0 ) {
311+ resultChannel.close()
312+ }
280313 }
281314 }
282315
283- val isSpam = resultChannel.receive()
284-
285- // Cancel all other jobs
286- jobs.forEach { it.cancel() }
316+ val isSpam = try {
317+ select<Boolean > {
318+ resultChannel.onReceiveCatching { result ->
319+ result.getOrNull() ? : false
320+ }
321+ onTimeout(timeoutMs) {
322+ Logger .getLogger(" SpamUtils" ).warning(
323+ " Spam check timed out after ${timeoutMs} ms for $number "
324+ )
325+ false
326+ }
327+ }
328+ } finally {
329+ jobs.forEach { it.cancel() }
330+ resultChannel.cancel()
331+ }
287332
288- return @coroutineScope isSpam
333+ isSpam
289334 }
290335
291336 private fun buildSpamCheckers (context : Context ): List <suspend (String ) -> Boolean > {
@@ -443,7 +488,6 @@ class SpamUtils {
443488 context.getString(R .string.incoming_call_not_spam),
444489 10000
445490 )
446- removeSpamNumber(context, number)
447491 }
448492 }
449493
0 commit comments