refactor(ble): Centralize BLE logic into a core module (#4550)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-20 06:41:52 -06:00 committed by GitHub
parent 7a68802bc2
commit 6bfa5b5f70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 3471 additions and 2405 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,11 +14,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Build
@ -73,3 +74,18 @@ fun Context.hasLocationPermission(): Boolean {
val perms = listOf(Manifest.permission.ACCESS_FINE_LOCATION)
return perms.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
}
/**
* Extension for Context to register a BroadcastReceiver in a compatible way across Android versions.
*
* @param receiver The receiver to register.
* @param filter The intent filter.
* @param flag The export flag (defaults to [ContextCompat.RECEIVER_EXPORTED]).
*/
fun Context.registerReceiverCompat(
receiver: BroadcastReceiver,
filter: IntentFilter,
flag: Int = ContextCompat.RECEIVER_EXPORTED,
) {
ContextCompat.registerReceiver(this, receiver, filter, flag)
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.content.Context
import java.io.File
import java.io.FileOutputStream
/**
* A specialized [FileOutputStream] that writes data to a file in the application's external files directory. Primarily
* used for low-level protocol debugging and packet logging.
*
* @param context The context used to locate the external files directory.
* @param name The name of the log file.
*/
class BinaryLogFile(context: Context, name: String) :
FileOutputStream(File(context.getExternalFilesDir(null), name), true)

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.os.Build
/** Utility for checking build properties, such as emulator detection. */
object BuildUtils {
/** Whether the app is currently running on an emulator. */
val isEmulator: Boolean
get() =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||
Build.FINGERPRINT.contains("emulator") ||
setOf(Build.MODEL, Build.PRODUCT).contains("google_sdk") ||
Build.MODEL.contains("sdk_gphone64") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for") ||
Build.MANUFACTURER.contains("Genymotion") ||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
}

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Parcel
import android.os.Parcelable
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import androidx.core.os.ParcelCompat
/** Reads a [Parcelable] from a [Parcel] in a backward-compatible way. */
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? =
ParcelCompat.readParcelable(this, loader, T::class.java)
/** Retrieves a [Parcelable] extra from an [Intent] in a backward-compatible way. */
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String?): T? =
IntentCompat.getParcelableExtra(this, key, T::class.java)
/** Retrieves [PackageInfo] for a given package name in a backward-compatible way. */
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION")
getPackageInfo(packageName, flags)
}
/** Registers a [BroadcastReceiver] using [ContextCompat] to ensure consistent behavior across Android versions. */
fun Context.registerReceiverCompat(
receiver: BroadcastReceiver,
filter: IntentFilter,
flag: Int = ContextCompat.RECEIVER_EXPORTED,
) {
ContextCompat.registerReceiver(this, receiver, filter, flag)
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.os.RemoteException
import co.touchlab.kermit.Logger
/**
* Wraps and discards exceptions, but reports them to the crash reporter before logging. Use this for operations that
* should not crash the process but are still unexpected.
*/
fun exceptionReporter(inner: () -> Unit) {
try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
}
}
/**
* Wraps an operation and converts any thrown exceptions into [RemoteException] for safe return through an AIDL
* interface.
*/
fun <T> toRemoteExceptions(inner: () -> T): T = try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Uncaught exception in service call, returning RemoteException to client" }
when (ex) {
is RemoteException -> throw ex
else -> throw RemoteException(ex.message).apply { initCause(ex) }
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import java.util.Date
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Awaits the latch for the given [Duration].
*
* @param timeout The maximum time to wait.
* @return `true` if the count reached zero and `false` if the waiting time elapsed before the count reached zero.
*/
fun CountDownLatch.await(timeout: Duration): Boolean = this.await(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)
/** Converts this [Instant] to a legacy [Date]. */
fun Instant.toDate(): Date = Date(this.toEpochMilliseconds())

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.text.InputFilter
import android.text.Spanned
/**
* An [InputFilter] that constrains text length based on UTF-8 byte count instead of character count. This is
* particularly useful for fields that must be stored in byte-limited buffers, such as hardware configuration fields.
*
* @param maxBytes The maximum allowed length in UTF-8 bytes.
*/
class Utf8ByteLengthFilter(private val maxBytes: Int) : InputFilter {
private companion object {
const val ONE_BYTE_LIMIT = '\u0080'
const val TWO_BYTE_LIMIT = '\u0800'
const val BYTES_1 = 1
const val BYTES_2 = 2
const val BYTES_3 = 3
}
override fun filter(
source: CharSequence,
start: Int,
end: Int,
dest: Spanned,
dstart: Int,
dend: Int,
): CharSequence? {
val srcByteCount = countUtf8Bytes(source, start, end)
// Calculate bytes in dest excluding the range being replaced
val destLen = dest.length
var destByteCount = 0
destByteCount += countUtf8Bytes(dest, 0, dstart)
destByteCount += countUtf8Bytes(dest, dend, destLen)
var keepBytes = maxBytes - destByteCount
return when {
keepBytes <= 0 -> ""
keepBytes >= srcByteCount -> null
else -> {
for (i in start until end) {
val c = source[i]
keepBytes -= getByteCount(c)
if (keepBytes < 0) {
return source.subSequence(start, i)
}
}
null
}
}
}
private fun countUtf8Bytes(seq: CharSequence, start: Int, end: Int): Int {
var count = 0
for (i in start until end) {
count += getByteCount(seq[i])
}
return count
}
private fun getByteCount(c: Char): Int = when {
c < ONE_BYTE_LIMIT -> BYTES_1
c < TWO_BYTE_LIMIT -> BYTES_2
else -> BYTES_3
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
private val errorHandler = CoroutineExceptionHandler { _, exception ->
Exceptions.report(exception, "coroutine-exception-handler", "Uncaught coroutine exception")
}
/**
* Launches a new coroutine with a central [CoroutineExceptionHandler] that reports errors to [Exceptions].
*
* @param context Additional to [CoroutineExceptionHandler] context.
* @param start Coroutine start option.
* @param block The coroutine code block.
* @return The launched [Job].
*/
fun CoroutineScope.handledLaunch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = context + errorHandler, start = start, block = block)

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import co.touchlab.kermit.Logger
/**
* Sometimes when starting services we face situations where messages come in that require computation but we can't do
* that computation yet because we are still waiting for some long running init to complete.
*
* This class lets you queue up closures to run at a later date and later on you can call run() to run all the
* previously queued work.
*/
class DeferredExecution {
private val queue = mutableListOf<() -> Unit>()
/** Queues new work to be executed later. */
fun add(fn: () -> Unit) {
queue.add(fn)
}
/** Runs all work in the queue and clears it. */
fun run() {
Logger.d { "Running deferred execution, numJobs=${queue.size}" }
queue.forEach { it() }
queue.clear()
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import co.touchlab.kermit.Logger
object Exceptions {
/** Set by the application to provide a custom crash reporting implementation. */
var reporter: ((Throwable, String?, String?) -> Unit)? = null
/**
* Report an exception to the configured reporter (if any) after logging it.
*
* @param exception The exception to report.
* @param tag An optional tag for the report.
* @param message An optional message providing context.
*/
fun report(exception: Throwable, tag: String? = null, message: String? = null) {
// Log locally first
Logger.e(exception) { "Exceptions.report: $tag $message" }
reporter?.invoke(exception, tag, message)
}
}
/** Wraps and discards exceptions, optionally logging them. */
fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
if (!silent) {
Logger.w(ex) { "Ignoring exception" }
}
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
/**
* A helper class that manages a single [Job]. When a new job is launched, the previous one is cancelled. This is useful
* for ensuring that only one operation of a certain type is running at a time.
*/
class SequentialJob @Inject constructor() {
private val job = AtomicReference<Job?>(null)
/**
* Cancels the previous job (if any) and launches a new one in the given [scope]. The new job uses [handledLaunch]
* to ensure exceptions are reported.
*/
fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) {
cancel()
val newJob = scope.handledLaunch(block = block)
job.set(newJob)
newJob.invokeOnCompletion { job.compareAndSet(newJob, null) }
}
/** Cancels the current job. */
fun cancel() {
job.getAndSet(null)?.cancel()
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/** A deferred execution object (with various possible implementations) */
interface Continuation<in T> {
fun resume(res: Result<T>)
/** Syntactic sugar for resuming with success. */
fun resumeSuccess(res: T) = resume(Result.success(res))
/** Syntactic sugar for resuming with failure. */
fun resumeWithException(ex: Throwable) = resume(Result.failure(ex))
}
/** An async continuation that calls a callback when the result is available. */
class CallbackContinuation<in T>(private val cb: (Result<T>) -> Unit) : Continuation<T> {
override fun resume(res: Result<T>) = cb(res)
}
/**
* A blocking version of coroutine Continuation using traditional threading primitives.
*
* This is useful in contexts where coroutine suspension is not desirable or when bridging with legacy threaded code.
*/
class SyncContinuation<T> : Continuation<T> {
private val lock = java.util.concurrent.locks.ReentrantLock()
private val condition = lock.newCondition()
private var result: Result<T>? = null
override fun resume(res: Result<T>) {
lock.lock()
try {
result = res
condition.signal()
} finally {
lock.unlock()
}
}
/**
* Blocks the current thread until the result is available or the timeout expires.
*
* @param timeoutMsecs Maximum time to wait in milliseconds. If 0, waits indefinitely.
* @return The result of the operation.
* @throws IllegalStateException if a timeout occurs or if an internal error happens.
*/
@Suppress("NestedBlockDepth")
fun await(timeoutMsecs: Long = 0): T {
lock.lock()
try {
val startT = nowMillis
while (result == null) {
if (timeoutMsecs > 0) {
val remaining = timeoutMsecs - (nowMillis - startT)
check(remaining > 0) { "SyncContinuation timeout" }
condition.await(remaining, java.util.concurrent.TimeUnit.MILLISECONDS)
} else {
condition.await()
}
}
val r = result
checkNotNull(r) { "Unexpected null result in SyncContinuation" }
return r.getOrThrow()
} finally {
lock.unlock()
}
}
}
/**
* Calls an [initfn] that is responsible for starting an operation and saving the [SyncContinuation]. Then blocks the
* current thread until the operation completes or times out.
*
* Essentially a blocking version of [kotlinx.coroutines.suspendCancellableCoroutine].
*/
fun <T> suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation<T>) -> Unit): T {
val cont = SyncContinuation<T>()
initfn(cont)
return cont.await(timeoutMsecs)
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlinx.datetime.TimeZone
import kotlin.time.Clock
import kotlin.time.Instant
/** Accessor for the current time in milliseconds. */
val nowMillis: Long
get() = nowInstant.toEpochMilliseconds()
/** Accessor for the current time as a stable [Instant]. */
val nowInstant: Instant
get() = Clock.System.now()
/** Accessor for the current time in seconds. */
val nowSeconds: Long
get() = nowInstant.epochSeconds
/** Accessor for the system default time zone. */
val systemTimeZone: TimeZone
get() = TimeZone.currentSystemDefault()
/** Converts these milliseconds to an [Instant]. */
fun Long.toInstant(): Instant = Instant.fromEpochMilliseconds(this)
/** Converts these seconds to an [Instant]. */
fun Int.secondsToInstant(): Instant = Instant.fromEpochSeconds(this.toLong())

View file

@ -0,0 +1,80 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class SequentialJobTest {
private val sequentialJob = SequentialJob()
@Test
fun `launch cancels previous job`() = runTest {
var job1Active = false
var job1Cancelled = false
// Launch first job
sequentialJob.launch(this) {
try {
job1Active = true
delay(1000)
} finally {
job1Cancelled = true
}
}
advanceTimeBy(100)
assertTrue(job1Active, "Job 1 should be active")
// Launch second job
sequentialJob.launch(this) {
// Do nothing
}
advanceTimeBy(100)
assertTrue(job1Cancelled, "Job 1 should be cancelled")
}
@Test
fun `cancel stops the job`() = runTest {
var jobActive = false
var jobCancelled = false
sequentialJob.launch(this) {
try {
jobActive = true
delay(1000)
} finally {
jobCancelled = true
}
}
advanceTimeBy(100)
assertTrue(jobActive, "Job should be active")
sequentialJob.cancel()
advanceTimeBy(100)
assertTrue(jobCancelled, "Job should be cancelled")
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertTrue
class TimeUtilsTest {
@Test
fun testNowMillis() {
val start = nowMillis
// Just verify it returns something sensible (not 0)
assertTrue(start > 0)
}
@Test
fun testNowSeconds() {
val start = nowSeconds
assertTrue(start > 0)
}
}