Extract scan functionality to its own class

Bug: 309745701
Test: build
Relnote: Extract scan functionality
Change-Id: I4d43f370b201c942bea0eda1230fef7ff6ef8aca
diff --git a/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricScanTest.kt b/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricScanTest.kt
index 3d727c3..63ca35b 100644
--- a/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricScanTest.kt
+++ b/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricScanTest.kt
@@ -16,76 +16,82 @@
 
 package androidx.bluetooth.testing
 
-import android.bluetooth.le.ScanResult
-import android.bluetooth.le.ScanSettings.CALLBACK_TYPE_ALL_MATCHES
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.bluetooth.le.ScanResult as FwkScanResult
+import android.content.Context
 import androidx.bluetooth.BluetoothLe
 import androidx.bluetooth.ScanFilter
-import java.util.concurrent.atomic.AtomicReference
-import kotlinx.coroutines.cancel
+import androidx.bluetooth.ScanImpl
+import androidx.bluetooth.ScanResult
+import junit.framework.TestCase.assertEquals
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
 import kotlinx.coroutines.flow.collectIndexed
-import kotlinx.coroutines.launch
 import kotlinx.coroutines.test.runTest
-import org.junit.Assert
+import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
 import org.robolectric.RuntimeEnvironment
-import org.robolectric.Shadows.shadowOf
-import org.robolectric.shadows.ShadowBluetoothDevice
-import org.robolectric.shadows.ShadowBluetoothLeScanner
 
 @RunWith(RobolectricTestRunner::class)
 @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
 class RobolectricScanTest {
 
-    private companion object {
-        private const val TIMEOUT_MS: Long = 2_000
-    }
+    private val context: Context = RuntimeEnvironment.getApplication()
+    private val bluetoothManager: BluetoothManager =
+        context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+    private val bluetoothAdapter: BluetoothAdapter = bluetoothManager.adapter
 
     private val bluetoothLe = BluetoothLe(RuntimeEnvironment.getApplication())
 
-    @Test
-    fun scanTest() = runTest {
-        val scanResults = listOf(
-            createScanResult("00:00:00:00:00:01"),
-            createScanResult("00:00:00:00:00:02"),
-            createScanResult("00:00:00:00:00:03"),
-        )
+    private val scanResults = listOf(
+        createScanResult("00:00:00:00:00:01"),
+        createScanResult("00:00:00:00:00:02"),
+        createScanResult("00:00:00:00:00:03")
+    )
 
-        val scannerRef = AtomicReference<ShadowBluetoothLeScanner>(null)
-        bluetoothLe.onStartScanListener = BluetoothLe.OnStartScanListener { scanner ->
-            val shadowScanner = shadowOf(scanner)
-            scannerRef.set(shadowScanner)
-
-            // Check if the scan is started
-            Assert.assertEquals(1, shadowScanner.activeScans.size)
-
-            shadowScanner.scanCallbacks.forEach { callback ->
-                scanResults.forEach { res ->
-                    callback.onScanResult(CALLBACK_TYPE_ALL_MATCHES, res)
-                }
-            }
-        }
-
-        launch {
-            bluetoothLe.scan(listOf(ScanFilter())).collectIndexed { index, value ->
-                Assert.assertEquals(scanResults[index].device.address, value.deviceAddress.address)
-                if (index == scanResults.size - 1) {
-                    this.cancel()
-                }
-            }
-        }.join()
-
-        // Check if the scan is stopped
-        Assert.assertEquals(0, scannerRef.get().activeScans.size)
+    @Before
+    fun setUp() {
+        bluetoothLe.scanImpl = ScanImplForTesting(scanResults)
     }
 
-    @Suppress("DEPRECATION")
+    @Test
+    fun scanTest() = runTest {
+        bluetoothLe.scan(listOf(ScanFilter()))
+            .collectIndexed { index, value ->
+                assertEquals(scanResults[index].deviceAddress.address, value.deviceAddress.address)
+        }
+    }
+
     private fun createScanResult(
         address: String,
-        rssi: Int = 0,
-        timestampNanos: Long = 0
     ): ScanResult {
-        return ScanResult(ShadowBluetoothDevice.newInstance(address), null, rssi, timestampNanos)
+        val fwkBluetoothDevice = bluetoothAdapter.getRemoteDevice(address)
+        val timeStampNanos: Long = 1
+        val rssi = 34
+        val periodicAdvertisingInterval = 8
+
+        // TODO(kihongs) Find a way to create framework ScanRecord and use in test
+        val fwkScanResult = FwkScanResult(
+            fwkBluetoothDevice,
+            1,
+            0,
+            0,
+            0,
+            0,
+            rssi,
+            periodicAdvertisingInterval,
+            null,
+            timeStampNanos
+        )
+        return ScanResult(fwkScanResult)
+    }
+}
+
+class ScanImplForTesting(val scanResults: List<ScanResult>) : ScanImpl {
+    override fun scan(filters: List<ScanFilter>): Flow<ScanResult> {
+        return scanResults.asFlow()
     }
 }
diff --git a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/BluetoothLe.kt b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/BluetoothLe.kt
index aaf0e98..9e7c9b5 100644
--- a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/BluetoothLe.kt
+++ b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/BluetoothLe.kt
@@ -17,20 +17,14 @@
 package androidx.bluetooth
 
 import android.bluetooth.BluetoothManager as FwkBluetoothManager
-import android.bluetooth.le.BluetoothLeScanner as FwkBluetoothLeScanner
-import android.bluetooth.le.ScanCallback as FwkScanCallback
-import android.bluetooth.le.ScanResult as FwkScanResult
-import android.bluetooth.le.ScanSettings as FwkScanSettings
 import android.content.Context
 import androidx.annotation.IntDef
 import androidx.annotation.RequiresPermission
 import androidx.annotation.RestrictTo
 import androidx.annotation.VisibleForTesting
 import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.emptyFlow
 import kotlinx.coroutines.flow.flowOf
 
 /**
@@ -74,6 +68,16 @@
         context.getSystemService(Context.BLUETOOTH_SERVICE) as FwkBluetoothManager?
     private val bluetoothAdapter = bluetoothManager?.adapter
 
+    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    var advertiseImpl: AdvertiseImpl? =
+        bluetoothAdapter?.bluetoothLeAdvertiser?.let(::getAdvertiseImpl)
+
+    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    var scanImpl: ScanImpl? =
+        bluetoothAdapter?.bluetoothLeScanner?.let(::getScanImpl)
+
     @VisibleForTesting
     @get:RestrictTo(RestrictTo.Scope.LIBRARY)
     val client: GattClient by lazy(LazyThreadSafetyMode.PUBLICATION) {
@@ -86,16 +90,6 @@
         GattServer(context.applicationContext)
     }
 
-    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    var advertiseImpl: AdvertiseImpl? =
-        bluetoothAdapter?.bluetoothLeAdvertiser?.let(::getAdvertiseImpl)
-
-    @VisibleForTesting
-    @get:RestrictTo(RestrictTo.Scope.LIBRARY)
-    @set:RestrictTo(RestrictTo.Scope.LIBRARY)
-    var onStartScanListener: OnStartScanListener? = null
-
     /**
      * Returns a _cold_ [Flow] to start Bluetooth LE advertising
      *
@@ -117,33 +111,15 @@
     /**
      * Returns a _cold_ [Flow] to start Bluetooth LE scanning.
      * Scanning is used to discover advertising devices nearby.
+     * Returns an `emptyFlow()` if bluetoothLeScanner is not available.
      *
      * @param filters [ScanFilter]s for finding exact Bluetooth LE devices
      *
      * @return a _cold_ [Flow] of [ScanResult] that matches with the given scan filter
      */
     @RequiresPermission("android.permission.BLUETOOTH_SCAN")
-    fun scan(filters: List<ScanFilter> = emptyList()): Flow<ScanResult> = callbackFlow {
-        val callback = object : FwkScanCallback() {
-            override fun onScanResult(callbackType: Int, result: FwkScanResult) {
-                trySend(ScanResult(result))
-            }
-
-            override fun onScanFailed(errorCode: Int) {
-                // TODO(b/270492198): throw precise exception
-                cancel("onScanFailed() called with: errorCode = $errorCode")
-            }
-        }
-
-        val bleScanner = bluetoothAdapter?.bluetoothLeScanner
-        val fwkFilters = filters.map { it.fwkScanFilter }
-        val scanSettings = FwkScanSettings.Builder().build()
-        bleScanner?.startScan(fwkFilters, scanSettings, callback)
-        onStartScanListener?.onStartScan(bleScanner)
-
-        awaitClose {
-            bleScanner?.stopScan(callback)
-        }
+    fun scan(filters: List<ScanFilter> = emptyList()): Flow<ScanResult> {
+        return scanImpl?.scan(filters) ?: emptyFlow()
     }
 
     /**
@@ -184,10 +160,4 @@
     ): R {
         return server.open(services, block)
     }
-
-    @VisibleForTesting
-    @RestrictTo(RestrictTo.Scope.LIBRARY)
-    fun interface OnStartScanListener {
-        fun onStartScan(scanner: FwkBluetoothLeScanner?)
-    }
 }
diff --git a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/Scan.kt b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/Scan.kt
new file mode 100644
index 0000000..785bfa6
--- /dev/null
+++ b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/Scan.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.bluetooth
+
+import android.bluetooth.le.BluetoothLeScanner as FwkBluetoothLeScanner
+import android.bluetooth.le.ScanCallback as FwkScanCallback
+import android.bluetooth.le.ScanResult as FwkScanResult
+import android.bluetooth.le.ScanSettings as FwkScanSettings
+import androidx.annotation.RequiresPermission
+import androidx.annotation.RestrictTo
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+interface ScanImpl {
+    fun scan(filters: List<ScanFilter> = emptyList()): Flow<ScanResult>
+}
+
+internal fun getScanImpl(bluetoothLeScanner: FwkBluetoothLeScanner): ScanImpl {
+    return ScanImplBase(bluetoothLeScanner)
+}
+
+private open class ScanImplBase(val bluetoothLeScanner: FwkBluetoothLeScanner) : ScanImpl {
+
+    @RequiresPermission("android.permission.BLUETOOTH_SCAN")
+    override fun scan(filters: List<ScanFilter>): Flow<ScanResult> = callbackFlow {
+        val callback = object : FwkScanCallback() {
+            override fun onScanResult(callbackType: Int, result: FwkScanResult) {
+                trySend(ScanResult(result))
+            }
+
+            override fun onScanFailed(errorCode: Int) {
+                // TODO(b/270492198): throw precise exception
+                cancel("onScanFailed() called with: errorCode = $errorCode")
+            }
+        }
+
+        val fwkFilters = filters.map { it.fwkScanFilter }
+        val fwkSettings = FwkScanSettings.Builder()
+            .build()
+
+        bluetoothLeScanner.startScan(fwkFilters, fwkSettings, callback)
+
+        awaitClose {
+            bluetoothLeScanner.stopScan(callback)
+        }
+    }
+}
diff --git a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/ScanResult.kt b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/ScanResult.kt
index 09f6d3b..13515c9 100644
--- a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/ScanResult.kt
+++ b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/ScanResult.kt
@@ -18,6 +18,7 @@
 
 import android.bluetooth.le.ScanResult as FwkScanResult
 import android.os.ParcelUuid
+import androidx.annotation.RestrictTo
 import java.util.UUID
 
 /**
@@ -35,7 +36,9 @@
  * bluetooth GATT services.
  *
  */
-class ScanResult internal constructor(private val fwkScanResult: FwkScanResult) {
+class ScanResult @RestrictTo(RestrictTo.Scope.LIBRARY) constructor(
+    private val fwkScanResult: FwkScanResult
+) {
 
     companion object {
         /**