Allow user to add multiple services & characteristics to GATT Server

Bug: 269390098
Test: build
Change-Id: I8da75986d0cc07399cf08207cdfd40378a835096
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserFragment.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserFragment.kt
index bef5649..248d2a9 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserFragment.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserFragment.kt
@@ -17,6 +17,7 @@
 package androidx.bluetooth.integration.testapp.ui.advertiser
 
 // TODO(ofy) Migrate to androidx.bluetooth.BluetoothLe once Gatt Server API is in place
+// TODO(ofy) Migrate to androidx.bluetooth.GattService
 import android.Manifest
 import android.annotation.SuppressLint
 import android.bluetooth.BluetoothGattCharacteristic
@@ -44,6 +45,8 @@
 import androidx.core.view.isVisible
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.viewModels
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.LinearLayoutManager
 import com.google.android.material.tabs.TabLayout
 import java.util.UUID
 import kotlinx.coroutines.CoroutineScope
@@ -61,7 +64,7 @@
 
     private lateinit var bluetoothLe: BluetoothLe
 
-    // TODO(ofy) Migrate to androidx.bluetooth.BluetoothLe once scan API is in place
+    // TODO(ofy) Migrate to androidx.bluetooth.BluetoothLe once openGattServer API is in place
     private lateinit var bluetoothLeExperimental: BluetoothLeExperimental
 
     private var advertiseDataAdapter: AdvertiseDataAdapter? = null
@@ -91,6 +94,8 @@
             _binding?.viewRecyclerViewOverlay?.isVisible = value
         }
 
+    private var gattServerServicesAdapter: GattServerServicesAdapter? = null
+
     private var isGattServerOpen: Boolean = false
         set(value) {
             field = value
@@ -182,6 +187,20 @@
             }
         }
 
+        binding.buttonAddService.setOnClickListener {
+            addGattService()
+        }
+
+        gattServerServicesAdapter =
+            GattServerServicesAdapter(
+                viewModel.gattServerServices,
+                ::addGattCharacteristic
+            )
+        binding.recyclerViewGattServerServices.adapter = gattServerServicesAdapter
+        binding.recyclerViewGattServerServices.addItemDecoration(
+            DividerItemDecoration(context, LinearLayoutManager.VERTICAL)
+        )
+
         binding.buttonGattServer.setOnClickListener {
             if (gattServerJob?.isActive == true) {
                 isGattServerOpen = false
@@ -340,26 +359,48 @@
         }
     }
 
-    private fun openGattServer() {
-        Log.d(TAG, "openGattServer() called")
+    private fun addGattService() {
+        // TODO(ofy) Show dialog for Service customization and replace sampleService
 
         val sampleService =
             BluetoothGattService(UUID.randomUUID(), BluetoothGattService.SERVICE_TYPE_PRIMARY)
+
+        viewModel.gattServerAddService(sampleService)
+
+        gattServerServicesAdapter?.notifyItemInserted(viewModel.gattServerServices.size - 1)
+    }
+
+    private fun addGattCharacteristic(bluetoothGattService: BluetoothGattService) {
+        // TODO(ofy) Show dialog for Characteristic customization and replace sampleCharacteristic
+
         val sampleCharacteristic = BluetoothGattCharacteristic(
             UUID.randomUUID(),
             BluetoothGattCharacteristic.PROPERTY_READ,
             BluetoothGattCharacteristic.PERMISSION_READ
         )
-        sampleService.addCharacteristic(sampleCharacteristic)
 
-        val services = listOf(sampleService)
+        bluetoothGattService.addCharacteristic(sampleCharacteristic)
+
+        gattServerServicesAdapter?.notifyItemChanged(
+            viewModel.gattServerServices.indexOf(
+                bluetoothGattService
+            )
+        )
+    }
+
+    private fun openGattServer() {
+        Log.d(TAG, "openGattServer() called")
 
         gattServerJob = gattServerScope.launch {
             isGattServerOpen = true
 
-            bluetoothLeExperimental.openGattServer(services).collect { gattServerCallback ->
-                Log.d(TAG, "openGattServer() called with: gattServerCallback = $gattServerCallback")
-            }
+            bluetoothLeExperimental.openGattServer(viewModel.gattServerServices)
+                .collect { gattServerCallback ->
+                    Log.d(
+                        TAG,
+                        "openGattServer() called with: gattServerCallback = $gattServerCallback"
+                    )
+                }
         }
     }
 }
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserViewModel.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserViewModel.kt
index f0e4cdd..2109813 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserViewModel.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/AdvertiserViewModel.kt
@@ -16,6 +16,8 @@
 
 package androidx.bluetooth.integration.testapp.ui.advertiser
 
+// TODO(ofy) Migrate to androidx.bluetooth.GattService
+import android.bluetooth.BluetoothGattService
 import androidx.bluetooth.AdvertiseParams
 import androidx.lifecycle.ViewModel
 import java.util.UUID
@@ -60,6 +62,8 @@
             serviceUuids
         )
 
+    val gattServerServices = mutableListOf<BluetoothGattService>()
+
     fun removeAdvertiseDataAtIndex(index: Int) {
         val manufacturerDataSize = manufacturerDatas.size
         val serviceDataSize = serviceDatas.size
@@ -72,4 +76,8 @@
             serviceUuids.removeAt(index - manufacturerDataSize - serviceDataSize)
         }
     }
+
+    fun gattServerAddService(bluetoothGattService: BluetoothGattService) {
+        gattServerServices.add(bluetoothGattService)
+    }
 }
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/GattServerCharacteristicsAdapter.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/GattServerCharacteristicsAdapter.kt
new file mode 100644
index 0000000..57608e1
--- /dev/null
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/GattServerCharacteristicsAdapter.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.integration.testapp.ui.advertiser
+
+import android.bluetooth.BluetoothGattCharacteristic
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.bluetooth.GattCharacteristic
+import androidx.bluetooth.integration.testapp.R
+import androidx.recyclerview.widget.RecyclerView
+
+class GattServerCharacteristicsAdapter(
+    private val characteristics: List<BluetoothGattCharacteristic>
+) : RecyclerView.Adapter<GattServerCharacteristicsAdapter.ViewHolder>() {
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        val view = LayoutInflater.from(parent.context)
+            .inflate(R.layout.item_gatt_server_characteristic, parent, false)
+        return ViewHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+        holder.bind(characteristics[position])
+    }
+
+    override fun getItemCount(): Int {
+        return characteristics.size
+    }
+
+    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+
+        private val textViewUuid: TextView = itemView.findViewById(R.id.text_view_uuid)
+        private val textViewProperties: TextView = itemView.findViewById(R.id.text_view_properties)
+
+        fun bind(characteristic: BluetoothGattCharacteristic) {
+            textViewUuid.text = characteristic.uuid.toString()
+
+            val properties = characteristic.properties
+            val context = itemView.context
+
+            val propertiesList = mutableListOf<String>()
+            // TODO(ofy) Update these with GattCharacteristic.isReadable, isWriteable, ...
+            if (properties.and(GattCharacteristic.PROPERTY_INDICATE) != 0) {
+                propertiesList.add(context.getString(R.string.indicate))
+            }
+            if (properties.and(GattCharacteristic.PROPERTY_NOTIFY) != 0) {
+                propertiesList.add(context.getString(R.string.notify))
+            }
+            val isReadable = properties.and(GattCharacteristic.PROPERTY_READ) != 0
+            if (isReadable) {
+                propertiesList.add(context.getString(R.string.read))
+            }
+            val isWriteable = (properties.and(GattCharacteristic.PROPERTY_WRITE) != 0 ||
+                properties.and(GattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0 ||
+                properties.and(GattCharacteristic.PROPERTY_SIGNED_WRITE) != 0)
+            if (isWriteable) {
+                propertiesList.add(context.getString(R.string.write))
+            }
+            textViewProperties.text = propertiesList.joinToString()
+        }
+    }
+}
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/GattServerServicesAdapter.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/GattServerServicesAdapter.kt
new file mode 100644
index 0000000..d2fd6c2
--- /dev/null
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/advertiser/GattServerServicesAdapter.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.integration.testapp.ui.advertiser
+
+import android.bluetooth.BluetoothGattService
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.TextView
+import androidx.bluetooth.integration.testapp.R
+import androidx.recyclerview.widget.RecyclerView
+
+class GattServerServicesAdapter(
+    private val services: List<BluetoothGattService>,
+    private val onClickAddCharacteristic: (BluetoothGattService) -> Unit
+) : RecyclerView.Adapter<GattServerServicesAdapter.ViewHolder>() {
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        val view = LayoutInflater.from(parent.context)
+            .inflate(R.layout.item_gatt_server_service, parent, false)
+        return ViewHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+        holder.bind(services[position])
+    }
+
+    override fun getItemCount(): Int {
+        return services.size
+    }
+
+    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+
+        private val textViewUuid: TextView = itemView.findViewById(R.id.text_view_uuid)
+        private val buttonAddCharacteristic: Button =
+            itemView.findViewById(R.id.button_add_characteristic)
+
+        private val recyclerViewServiceCharacteristic: RecyclerView =
+            itemView.findViewById(R.id.recycler_view_service_characteristic)
+
+        private var currentBluetoothGattService: BluetoothGattService? = null
+
+        init {
+            buttonAddCharacteristic.setOnClickListener {
+                currentBluetoothGattService?.let(onClickAddCharacteristic)
+            }
+        }
+
+        fun bind(bluetoothGattService: BluetoothGattService) {
+            currentBluetoothGattService = bluetoothGattService
+
+            textViewUuid.text = bluetoothGattService.uuid.toString()
+
+            recyclerViewServiceCharacteristic.adapter = GattServerCharacteristicsAdapter(
+                bluetoothGattService.characteristics
+            )
+        }
+    }
+}
diff --git a/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_advertiser.xml b/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_advertiser.xml
index 024d732..4181f87 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_advertiser.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_advertiser.xml
@@ -164,6 +164,37 @@
         app:layout_constraintTop_toBottomOf="@+id/tab_layout"
         tools:visibility="visible">
 
+        <androidx.core.widget.NestedScrollView
+            android:layout_width="match_parent"
+            android:layout_height="0dp"
+            app:layout_constraintTop_toBottomOf="parent"
+            app:layout_constraintTop_toTopOf="parent">
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:orientation="vertical">
+
+                <androidx.recyclerview.widget.RecyclerView
+                    android:id="@+id/recycler_view_gatt_server_services"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    app:layoutManager="LinearLayoutManager"
+                    tools:itemCount="1"
+                    tools:listitem="@layout/item_gatt_server_service" />
+
+                <Button
+                    android:id="@+id/button_add_service"
+                    style="@style/Widget.MaterialComponents.Button.TextButton"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_margin="16dp"
+                    android:text="@string/add_service" />
+
+            </LinearLayout>
+
+        </androidx.core.widget.NestedScrollView>
+
         <Button
             android:id="@+id/button_gatt_server"
             android:layout_width="wrap_content"
diff --git a/bluetooth/integration-tests/testapp/src/main/res/layout/item_gatt_server_characteristic.xml b/bluetooth/integration-tests/testapp/src/main/res/layout/item_gatt_server_characteristic.xml
new file mode 100644
index 0000000..64bb680
--- /dev/null
+++ b/bluetooth/integration-tests/testapp/src/main/res/layout/item_gatt_server_characteristic.xml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  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.
+  -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:padding="8dp">
+
+    <LinearLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/uuid"
+            android:textAllCaps="true" />
+
+        <TextView
+            android:id="@+id/text_view_uuid"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="4dp"
+            android:textColor="@color/black"
+            tools:text="0x1800" />
+
+    </LinearLayout>
+
+    <LinearLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/properties" />
+
+        <TextView
+            android:id="@+id/text_view_properties"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="4dp"
+            android:textAllCaps="true"
+            android:textColor="@color/black"
+            tools:text="@string/read" />
+
+    </LinearLayout>
+
+    <!-- TODO(ofy) Add Descriptor -->
+
+    <!--    <Button-->
+    <!--        android:id="@+id/button_add_descriptor"-->
+    <!--        style="@style/Widget.MaterialComponents.Button.TextButton"-->
+    <!--        android:layout_width="wrap_content"-->
+    <!--        android:layout_height="wrap_content"-->
+    <!--        android:layout_marginStart="16dp"-->
+    <!--        android:text="@string/add_descriptor" />-->
+
+</LinearLayout>
diff --git a/bluetooth/integration-tests/testapp/src/main/res/layout/item_gatt_server_service.xml b/bluetooth/integration-tests/testapp/src/main/res/layout/item_gatt_server_service.xml
new file mode 100644
index 0000000..bbb6557
--- /dev/null
+++ b/bluetooth/integration-tests/testapp/src/main/res/layout/item_gatt_server_service.xml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  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.
+  -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:padding="8dp">
+
+    <TextView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="@string/generic_attribute"
+        android:textColor="@color/black"
+        android:textStyle="bold" />
+
+    <LinearLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/uuid"
+            android:textAllCaps="true" />
+
+        <TextView
+            android:id="@+id/text_view_uuid"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="4dp"
+            android:textColor="@color/black"
+            tools:text="0x1800" />
+
+    </LinearLayout>
+
+    <TextView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="@string/primary_service"
+        android:textAllCaps="true" />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/recycler_view_service_characteristic"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="16dp"
+        app:layoutManager="LinearLayoutManager"
+        tools:itemCount="3"
+        tools:listitem="@layout/item_gatt_server_characteristic" />
+
+    <Button
+        android:id="@+id/button_add_characteristic"
+        style="@style/Widget.MaterialComponents.Button.TextButton"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="16dp"
+        android:text="@string/add_characteristic" />
+
+</LinearLayout>
diff --git a/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml b/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
index 06dffe7..fa1ad57 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
@@ -67,6 +67,9 @@
 
     <!-- GATT Server -->
     <string name="gatt_server">Gatt Server</string>
+    <string name="add_service">Add Service</string>
+    <string name="add_characteristic">Add Characteristic</string>
+    <string name="add_descriptor">Add Descriptor</string>
     <string name="open_gatt_server">Open Gatt Server</string>
     <string name="stop_gatt_server">Stop Gatt Server</string>
 </resources>