diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index 897dfb61..ef58ab39 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -87,8 +87,8 @@ android { create("Si-Connect") { dimension = versionDim applicationId = "com.siliconlabs.bledemo" - versionCode = 82 - versionName = "3.3.0" + versionCode = 83 + versionName = "3.3.1" } } diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/demo/throughput/views/SpeedView.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/demo/throughput/views/SpeedView.kt index b68ef20b..a6020485 100644 --- a/mobile/src/main/java/com/siliconlabs/bledemo/features/demo/throughput/views/SpeedView.kt +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/demo/throughput/views/SpeedView.kt @@ -58,6 +58,20 @@ class SpeedView(context: Context, attributeSet: AttributeSet? = null) : View(con private val positions = floatArrayOf(0f, 0.3f, 1f) + private companion object { + private const val ARC_START_DEGREES = 135f + private const val ARC_SWEEP_DEGREES = 270f + /** Anchor path for scale labels (fractions of view width/height), tuned for the 9-tick layout. */ + private val LABEL_ANCHOR_X = floatArrayOf( + 0.22f, 0.12f, 0.13f, 0.23f, 0.50f, 0.77f, 0.87f, 0.88f, 0.78f + ) + private val LABEL_ANCHOR_Y = floatArrayOf( + 0.77f, 0.59f, 0.38f, 0.23f, 0.14f, 0.23f, 0.38f, 0.59f, 0.77f + ) + private const val SEVEN_LABEL_TEXT_SIZE_FRACTION = 0.041f + private const val SEVEN_LABEL_PATH_SPREAD = 1.05f + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) mHeight = getDefaultSize(suggestedMinimumHeight, heightMeasureSpec).toFloat() @@ -152,8 +166,8 @@ class SpeedView(context: Context, attributeSet: AttributeSet? = null) : View(con } fun setUnitsArray(array: ArrayList) { - if (array.size != 9) { - throw IllegalArgumentException("You should provide array containing 9 elements") + if (array.size != 7 && array.size != 9) { + throw IllegalArgumentException("You should provide array containing 7 or 9 elements") } this.unitsArray = array } @@ -162,21 +176,21 @@ class SpeedView(context: Context, attributeSet: AttributeSet? = null) : View(con override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - val startAngle = (135f + (progress / 100.0) * 270).toFloat() - val sweepAngle = (270 * (100.0 - progress) / 100.0).toFloat() + val startAngle = (ARC_START_DEGREES + (progress / 100.0) * ARC_SWEEP_DEGREES).toFloat() + val sweepAngle = (ARC_SWEEP_DEGREES * (100.0 - progress) / 100.0).toFloat() val px = (mWidth / 2.0).toFloat() val py = (mHeight / 2.0).toFloat() mMatrix.apply { reset() indicatorBitmap?.let { postTranslate(-(0.188 * it.width).toFloat(), -(it.height * 0.436).toFloat()) } - postRotate(135f + ((progress / 100.0) * 270.0).toFloat()) + postRotate(ARC_START_DEGREES + ((progress / 100.0) * ARC_SWEEP_DEGREES).toFloat()) postTranslate(px, py) } canvas.apply { indicatorBitmap?.let { drawBitmap(it, mMatrix, indicatorPaint) } - drawArc(rectangle, 135f, 270f, false, gradientPaintRing) + drawArc(rectangle, ARC_START_DEGREES, ARC_SWEEP_DEGREES, false, gradientPaintRing) drawArc(rectangle, startAngle, sweepAngle, false, greyPaintRing) drawUnits(this) drawSpeed(this) @@ -185,8 +199,60 @@ class SpeedView(context: Context, attributeSet: AttributeSet? = null) : View(con } private fun drawUnits(canvas: Canvas) { - if (unitsArray.size > 0) { - canvas.apply { + if (unitsArray.isEmpty()) return + when (unitsArray.size) { + 7 -> drawUnitsEvenlySpaced(canvas) + else -> drawUnitsLegacy(canvas) + } + } + + /** + * Evenly spaces [labelCount] ticks along the full gauge arc using the legacy anchor path + * (start at index 0, end at index 8) so the max value sits at the lower-right arc end. + */ + private fun drawUnitsEvenlySpaced(canvas: Canvas) { + val labelCount = unitsArray.size + val maxAnchorIndex = LABEL_ANCHOR_X.size - 1 + val savedTextSize = unitPaint.textSize + unitPaint.textSize = mWidth * SEVEN_LABEL_TEXT_SIZE_FRACTION + + unitsArray.forEachIndexed { index, label -> + val pathPosition = index.toFloat() / (labelCount - 1) * maxAnchorIndex + val segmentIndex = pathPosition.toInt().coerceIn(0, maxAnchorIndex - 1) + val segmentFraction = pathPosition - segmentIndex + val xFraction = spreadLabelFraction( + LABEL_ANCHOR_X[segmentIndex] + + segmentFraction * (LABEL_ANCHOR_X[segmentIndex + 1] - LABEL_ANCHOR_X[segmentIndex]) + ) + val yFraction = spreadLabelFraction( + LABEL_ANCHOR_Y[segmentIndex] + + segmentFraction * (LABEL_ANCHOR_Y[segmentIndex + 1] - LABEL_ANCHOR_Y[segmentIndex]) + ) + val x = mWidth * xFraction + val y = mHeight * yFraction + + unitPaint.textAlign = labelTextAlignForPosition(x, y) + canvas.drawText(label, x, y, unitPaint) + } + unitPaint.textSize = savedTextSize + } + + private fun spreadLabelFraction(fraction: Float): Float { + return 0.5f + (fraction - 0.5f) * SEVEN_LABEL_PATH_SPREAD + } + + private fun labelTextAlignForPosition(x: Float, y: Float): Paint.Align { + return when { + y <= mHeight * 0.18f -> Paint.Align.CENTER + x <= mWidth * 0.28f -> Paint.Align.LEFT + x >= mWidth * 0.72f -> Paint.Align.RIGHT + x < mWidth * 0.5f -> Paint.Align.LEFT + else -> Paint.Align.RIGHT + } + } + + private fun drawUnitsLegacy(canvas: Canvas) { + canvas.apply { // LEFT unitPaint.textAlign = Paint.Align.LEFT drawText(unitsArray[0], (mWidth * 0.22).toFloat(), (mHeight * 0.77).toFloat(), unitPaint) @@ -202,9 +268,10 @@ class SpeedView(context: Context, attributeSet: AttributeSet? = null) : View(con unitPaint.textAlign = Paint.Align.RIGHT drawText(unitsArray[5], (mWidth * 0.77).toFloat(), (mHeight * 0.23).toFloat(), unitPaint) drawText(unitsArray[6], (mWidth * 0.87).toFloat(), (mHeight * 0.38).toFloat(), unitPaint) - drawText(unitsArray[7], (mWidth * 0.88).toFloat(), (mHeight * 0.59).toFloat(), unitPaint) - drawText(unitsArray[8], (mWidth * 0.78).toFloat(), (mHeight * 0.77).toFloat(), unitPaint) - } + if (unitsArray.size > 7) { + drawText(unitsArray[7], (mWidth * 0.88).toFloat(), (mHeight * 0.59).toFloat(), unitPaint) + drawText(unitsArray[8], (mWidth * 0.78).toFloat(), (mHeight * 0.77).toFloat(), unitPaint) + } } } diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/demo/wifi_provisioning/activities/WiFiProvisioningActivity.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/demo/wifi_provisioning/activities/WiFiProvisioningActivity.kt index f0aebe11..1620f891 100644 --- a/mobile/src/main/java/com/siliconlabs/bledemo/features/demo/wifi_provisioning/activities/WiFiProvisioningActivity.kt +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/demo/wifi_provisioning/activities/WiFiProvisioningActivity.kt @@ -28,6 +28,7 @@ import com.siliconlabs.bledemo.features.demo.wifi_provisioning.adapters.APAdapte import com.siliconlabs.bledemo.features.demo.wifi_provisioning.fragment.WiFiInputDialogFragment import com.siliconlabs.bledemo.features.demo.wifi_provisioning.interfaces.WiFiProvisionInterface import com.siliconlabs.bledemo.features.demo.wifi_provisioning.model.ScanResult +import com.google.gson.GsonBuilder import com.siliconlabs.bledemo.utils.AppUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -111,7 +112,7 @@ class WiFiProvisioningActivity : AppCompatActivity(), try { val url = "http://$ipAddress" val retro = Retrofit.Builder().baseUrl(url) - .addConverterFactory(GsonConverterFactory.create()).build() + .addConverterFactory(GsonConverterFactory.create(provisioningGson)).build() val response = retro.create(WiFiProvisionInterface::class.java).getWiFiProvisionScanner() @@ -302,7 +303,7 @@ class WiFiProvisioningActivity : AppCompatActivity(), // val okHttpClient = setHTTPstatus() val retro = Retrofit.Builder().baseUrl(url) // .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()).build() + .addConverterFactory(GsonConverterFactory.create(provisioningGson)).build() val provisionStatus = retro.create(WiFiProvisionInterface::class.java) val body = mapOf( AP_SSID to ssid, @@ -391,6 +392,9 @@ class WiFiProvisioningActivity : AppCompatActivity(), private const val IP_ADDRESS = "192.168.10.10" private val TAG = Companion::class.java.simpleName + // Gson default escapes ' as \u0027; the dev kit HTTP server does not decode that. + private val provisioningGson = GsonBuilder().disableHtmlEscaping().create() + private const val DIALOG_WIFI_PROV_INPUT_TAG = "WiFiProvDialogFragmentTAG" const val AP_SSID = "ssid" const val AP_PASSPHRASE = "passphrase" diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/activities/IOPExpertListener.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/activities/IOPExpertListener.kt new file mode 100644 index 00000000..62eb992b --- /dev/null +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/activities/IOPExpertListener.kt @@ -0,0 +1,9 @@ +package com.siliconlabs.bledemo.features.iop_test.activities + +import com.siliconlabs.bledemo.features.iop_test.models.IOPExpertLogEntry + +interface IOPExpertListener { + fun appendLogEntry(entry: IOPExpertLogEntry) + fun restoreLog(entries: List) + fun clearLog() +} diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/activities/IOPTestActivity.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/activities/IOPTestActivity.kt index 60768613..4586fe8f 100644 --- a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/activities/IOPTestActivity.kt +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/activities/IOPTestActivity.kt @@ -19,6 +19,7 @@ package com.siliconlabs.bledemo.features.iop_test.activities import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog +import androidx.lifecycle.lifecycleScope import android.bluetooth.* import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback @@ -39,6 +40,8 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.widget.AppCompatButton import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources @@ -47,7 +50,10 @@ import androidx.core.content.ContextCompat.startActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.get -import androidx.lifecycle.lifecycleScope +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayoutMediator import com.siliconlabs.bledemo.R import com.siliconlabs.bledemo.bluetooth.ble.GattCharacteristic import com.siliconlabs.bledemo.bluetooth.ble.GattService @@ -55,8 +61,12 @@ import com.siliconlabs.bledemo.bluetooth.ble.TimeoutGattCallback import com.siliconlabs.bledemo.bluetooth.services.BluetoothService import com.siliconlabs.bledemo.databinding.ActivityIopTestBinding import com.siliconlabs.bledemo.databinding.DialogShareIopLogBinding +import com.siliconlabs.bledemo.features.iop_test.dialogs.IOPGattInfoDialog +import com.siliconlabs.bledemo.features.iop_test.fragments.IOPExpertFragment +import com.siliconlabs.bledemo.features.iop_test.fragments.IOPExpertFragment.Companion.newExpertInstance import com.siliconlabs.bledemo.features.iop_test.fragments.IOPTestFragment import com.siliconlabs.bledemo.features.iop_test.fragments.IOPTestFragment.Companion.newInstance +import com.siliconlabs.bledemo.features.iop_test.models.IOPExpertLogEntry import com.siliconlabs.bledemo.features.iop_test.models.* import com.siliconlabs.bledemo.features.iop_test.models.Common.Companion.isSetProperty import com.siliconlabs.bledemo.features.iop_test.models.IOPTest.Companion.createDataTest @@ -67,6 +77,7 @@ import com.siliconlabs.bledemo.features.iop_test.test_cases.ota.OtaFileManager import com.siliconlabs.bledemo.features.iop_test.test_cases.ota.OtaFileSelectionDialog import com.siliconlabs.bledemo.features.iop_test.test_cases.ota.OtaProgressDialog import com.siliconlabs.bledemo.features.iop_test.utils.ErrorCodes +import com.siliconlabs.bledemo.features.demo.throughput.views.SpeedView import com.siliconlabs.bledemo.features.scan.browser.dialogs.OtaLoadingDialog import com.siliconlabs.bledemo.home_screen.dialogs.SelectDeviceDialog import com.siliconlabs.bledemo.utils.AppUtil @@ -116,6 +127,13 @@ class IOPTestActivity : AppCompatActivity() { private var countReTest = 0 private var iopPhase3IndexStartChildrenTest = -1 private var iopPhase3BondingStep = 2 + private var securityAwaitingReadAfterBondRemove = false + /** True after security control 0x02/0x03 write until firmware disconnects. */ + private var securityWaitingForFirmwareDisconnect = false + private var securityPendingReadIndex = -1 + private var securityPendingControlByte = -1 + private var gattDiscoveryInProgress = false + private var bondedDiscoveryRetryToken = 0 private var iopPhase3ExtraDescriptor: BluetoothGattDescriptor? = null private var read_CCCD_value = ByteArray(1) var isCCCDPass = true @@ -148,8 +166,50 @@ class IOPTestActivity : AppCompatActivity() { private var mByteNumReceived = 0 private var mPDULength = 0 private var mByteSpeed = 0 + private var mPeakBitsPerSec = 0 private var mEndThroughputNotification = false + private var throughputSpeedDialog: AlertDialog? = null + private var throughputSpeedView: SpeedView? = null + private var throughputDialogMtuText: TextView? = null + private var throughputDialogBufferText: TextView? = null + private var throughputDialogPeakText: TextView? = null + private var throughputDialogAverageText: TextView? = null + private var throughputDialogThresholdText: TextView? = null + private var throughputDialogDoneButton: AppCompatButton? = null + private var pendingThroughputDescriptorStatus: Int? = null + private var throughputDescriptorWriteStatus: Int = 0 + private var isThroughputMeterActive = false + + private val throughputMeterUpdateRunnable = object : Runnable { + override fun run() { + if (!isThroughputMeterActive) return + throughputSpeedView?.let { sv -> + val elapsed = System.currentTimeMillis() - mStartTimeThroughput + val speedBitsPerSec = + if (elapsed > 0) (mByteNumReceived * 8L * 1000 / elapsed).toInt() else 0 + mPeakBitsPerSec = maxOf(mPeakBitsPerSec, speedBitsPerSec) + sv.updateSpeed( + iopThroughputProgressForSpeed(speedBitsPerSec), + iopThroughputSpeedAsString(speedBitsPerSec), + iopThroughputUnitAsString(speedBitsPerSec), + SpeedView.Mode.DOWNLOAD + ) + } + updateThroughputDialogMtuBufferLabels() + if (isThroughputMeterActive) { + handler?.postDelayed(this, 200L) + } + } + } + + private val throughputDialogAutoDismissRunnable = Runnable { + if (throughputSpeedDialog?.isShowing != true) return@Runnable + val status = pendingThroughputDescriptorStatus ?: 0 + pendingThroughputDescriptorStatus = null + continueIopTestAfterThroughputDescriptorWrite(status) + } + private var otaProgressDialog: OtaProgressDialog? = null private var otaLoadingDialog: OtaLoadingDialog? = null private var otaFileSelectionDialog: OtaFileSelectionDialog? = null @@ -172,6 +232,10 @@ class IOPTestActivity : AppCompatActivity() { private var mtu = 247 private var currentRxPhy: Int? = null private var mtuDivisible = 0 + private var otaPacketSizeWithAck = 0 + private var otaPacketSizeWithoutAck = 0 + private var currentOtaPacketSize = 0 + private val expertLoggedChildResults = HashSet() private var isServiceChangedIndication = 1 private var isConnecting = false @@ -186,10 +250,21 @@ class IOPTestActivity : AppCompatActivity() { private var testCaseCount = 0 private var shareMenuItem: MenuItem? = null + private var gattInfoMenuItem: MenuItem? = null + private var isExpertTabSelected = false + private var mExpertListener: IOPExpertListener? = null + private val expertLogEntries = ArrayList() + private val pendingExpertLogEntries = ArrayList() + private var expertLogFlushRunnable: Runnable? = null + private var pendingExpertOtaAutoContinue = false + private var isExpertLogData = false + private var expertFragment: IOPExpertFragment? = null + private val expertLogLock = Any() private lateinit var binding: ActivityIopTestBinding /** Prevents showing the post-run bonding dialog more than once per test run. */ private var bondingRemovedDialogShownThisRun = false + private var endOfTestCleanupPerformed = false /** User must acknowledge the Security-step briefing before the IOP Security flow runs. */ private var iopSecurityIntroAcknowledged = false @@ -210,25 +285,35 @@ class IOPTestActivity : AppCompatActivity() { Log.d(TAG, msg) if (state == BluetoothDevice.BOND_BONDED && prevState == BluetoothDevice.BOND_BONDING) { handler?.postDelayed({ + if (isTestFinished || !isTestRunning || endOfTestCleanupPerformed) { + return@postDelayed + } if (getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_SECURITY].getStatusTest() == Common.IOP3_TC_STATUS_PROCESSING || getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_LE_PRIVACY].getStatusTest() == Common.IOP3_TC_STATUS_PROCESSING /* || getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_CACHING] .getStatusTest() == Common.IOP3_TC_STATUS_PROCESSING*/) { - if (mBluetoothGatt != null) { - mListCharacteristics.clear() - characteristicsPhase3Security.clear() - Log.d(TAG, "discovering services with 1600ms delay") - Log.d(TAG, "isConnected: $isConnected") - val result = mBluetoothGatt!!.discoverServices() - if (!result) { - Log.e(TAG, "discoverServices failed to start") - } - } else { - retryIOP3Failed(mIndexRunning, ++countReTest / 2) + mListCharacteristics.clear() + characteristicsPhase3Security.clear() + Log.d(TAG, "discovering services after bond; isConnected=$isConnected") + if (!startGattServiceDiscovery("bondStateBonded")) { + scheduleBondedServiceDiscoveryRetry() } } - }, 1600) + }, BOND_COMPLETE_DISCOVERY_DELAY_MS) + } else if (state == BluetoothDevice.BOND_NONE && prevState == BluetoothDevice.BOND_BONDED) { + if (securityAwaitingReadAfterBondRemove + && !securityWaitingForFirmwareDisconnect + && mIndexRunning == POSITION_TEST_IOP3_SECURITY + && isTestRunning + && !isTestFinished + && !endOfTestCleanupPerformed + ) { + scheduleSecurityAfterBondRemove(BOND_REMOVE_DELAY_MS) + } } else if (state == BluetoothDevice.BOND_BONDING) { + if (isTestFinished || !isTestRunning) { + return + } /* Toast.makeText( this@IOPTestActivity, R.string.iop_test_toast_bonding, @@ -297,6 +382,7 @@ class IOPTestActivity : AppCompatActivity() { */ private fun updateDataTestFailed(index: Int) { Log.d(TAG, "updateDataTestFailed $index") + dismissThroughputSpeedDialog() when (index) { POSITION_TEST_SCANNER -> { scanLeDevice(false) @@ -319,6 +405,23 @@ class IOPTestActivity : AppCompatActivity() { if (index >= POSITION_TEST_SCANNER) { isTestRunning = false isTestFinished = true + val otaSummary = if (otaPacketSizeWithAck > 0 && otaPacketSizeWithoutAck > 0) { + getString( + R.string.iop_expert_log_ota_packet_size_summary, + otaPacketSizeWithAck, + otaPacketSizeWithoutAck, + mtu + ) + } else { + null + } + postExpertLog( + category = "RUN", + title = getString(R.string.iop_expert_log_test_finished), + detail = otaSummary, + tone = "success" + ) + cancelPendingIopBluetoothWork() if (isConnected) { isConnected = false mBluetoothGatt?.disconnect() @@ -362,6 +465,7 @@ class IOPTestActivity : AppCompatActivity() { isTestRunning = true isTestFinished = false isConnecting = false + logExpertScenarioStart(item) Log.d(TAG, "startItemTest: setStatusTest PROCESSING, item: $item") getSiliconLabsTestInfo().listItemTest[item].setStatusTest(Common.IOP3_TC_STATUS_PROCESSING) handler?.postDelayed({ @@ -490,12 +594,22 @@ class IOPTestActivity : AppCompatActivity() { } POSITION_TEST_IOP3_THROUGHPUT -> { - val throughputAcceptable = calculateAcceptableThroughput() - itemTestCaseInfo.setThroughputBytePerSec(mByteSpeed, throughputAcceptable) - Log.d( - TAG, - "finishItemTest: POSITION_TEST_IOP3_THROUGHPUT mByteSpeed $mByteSpeed throughputAcceptable $throughputAcceptable" - ) + dismissThroughputSpeedDialog() + recalculateFinalThroughputByteSpeed() + if (throughputDescriptorWriteStatus == 0) { + val throughputAcceptable = calculateAcceptableThroughput() + itemTestCaseInfo.setThroughputBytePerSec(mByteSpeed, throughputAcceptable) + Log.d( + TAG, + "finishItemTest: POSITION_TEST_IOP3_THROUGHPUT mByteSpeed $mByteSpeed throughputAcceptable $throughputAcceptable" + ) + } else { + Log.e( + TAG, + "finishItemTest: POSITION_TEST_IOP3_THROUGHPUT notification disable failed, status $throughputDescriptorWriteStatus" + ) + itemTestCaseInfo.setStatusTest(Common.IOP3_TC_STATUS_FAILED) + } countReTest = 0 } @@ -505,12 +619,16 @@ class IOPTestActivity : AppCompatActivity() { countReTest = 0 isTestRunning = false isTestFinished = true + cancelPendingIopBluetoothWork() } POSITION_TEST_IOP3_SECURITY -> { getItemTestCaseInfo(POSITION_TEST_IOP3_SECURITY).checkStatusItemService() Log.d(TAG, "finishItemTest: POSITION_TEST_IOP3_SECURITY") countReTest = 0 + clearSecurityBondRemovePending() + handler?.removeCallbacks(iopSecurityRunnable) + bondedDiscoveryRetryToken++ // isTestRunning = false // isTestFinished = true mBluetoothService?.isNotificationEnabled = true @@ -528,7 +646,12 @@ class IOPTestActivity : AppCompatActivity() { } } if (item != POSITION_TEST_IOP3_LE_PRIVACY && countReTest == 0) { - startItemTest(item + 1) + if (item == POSITION_TEST_IOP3_THROUGHPUT) { + // Defer so the throughput dialog can fully dismiss before Security intro popup. + handler?.postDelayed({ startItemTest(item + 1) }, 300) + } else { + startItemTest(item + 1) + } } /*if (item != POSITION_TEST_IOP3_SECURITY && countReTest == 0) { startItemTest(item + 1) @@ -536,6 +659,8 @@ class IOPTestActivity : AppCompatActivity() { + logExpertScenarioResult(item, itemTestCaseInfo) + runOnUiThread { updateUIFooter(isTestRunning) } mListener?.updateUi() } @@ -618,6 +743,7 @@ class IOPTestActivity : AppCompatActivity() { } } itemChildrenTest.setDataAndCompareResult(characteristic) + logExpertChildTestResultIfNeeded(POSITION_TEST_SERVICE, itemChildrenTest) } } for (itemChildrenTest: ChildrenItemTestInfo in getListChildrenItemTestCase( @@ -632,6 +758,7 @@ class IOPTestActivity : AppCompatActivity() { } } itemChildrenTest.setDataAndCompareResult(characteristic) + logExpertChildTestResultIfNeeded(POSITION_TEST_IOP3_SECURITY, itemChildrenTest) } } @@ -660,6 +787,18 @@ class IOPTestActivity : AppCompatActivity() { } else if (characteristicIOPPhase3Control?.uuid.toString() == characteristic.uuid.toString()) { if (getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_THROUGHPUT].getStatusTest() == Common.IOP3_TC_STATUS_PROCESSING) { iopPhase3RunTestCaseThroughput(0) + } + if (mIndexRunning == POSITION_TEST_IOP3_SECURITY + && getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_SECURITY].getStatusTest() == Common.IOP3_TC_STATUS_PROCESSING + && type == 1 + && status == 0 + ) { + when (securityControlByteFromControlWrite(characteristic.value)) { + IOP_SECURITY_CONTROL_AUTHENTICATION -> + removeBondAfterSecurityControlWrite(IOP_SECURITY_CONTROL_AUTHENTICATION) + IOP_SECURITY_CONTROL_BONDING -> + removeBondAfterSecurityControlWrite(IOP_SECURITY_CONTROL_BONDING) + } } /*else if (getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_LE_PRIVACY].getStatusTest() == Common.IOP3_TC_STATUS_PROCESSING) { iopPhase3RunTestCaseLEPrivacy(0) finishItemTest( @@ -742,8 +881,13 @@ class IOPTestActivity : AppCompatActivity() { */ fun reconnect(delaytoconnect: Long) { if (!isInAppOTA) { - mBluetoothDevice = mBluetoothGatt?.device - if (mBluetoothService?.isGattConnected()!!) { + if (isTestFinished || !isTestRunning || isFinishing || endOfTestCleanupPerformed) { + Log.d(TAG, "reconnect ignored: test ended") + return + } + gattDiscoveryInProgress = false + mBluetoothDevice = mBluetoothDevice ?: mBluetoothGatt?.device + if (mBluetoothService?.isGattConnected() == true) { mBluetoothService?.clearConnectedGatt() } @@ -757,6 +901,10 @@ class IOPTestActivity : AppCompatActivity() { reconnectTimer?.schedule(object : TimerTask() { override fun run() { + if (isTestFinished || !isTestRunning || isFinishing) { + Log.d(TAG, "reconnect skipped: test ended") + return + } Log.d(TAG, "Attempting connection...") mBluetoothBinding = object : BluetoothService.Binding(applicationContext) { override fun onBound(service: BluetoothService?) { @@ -837,6 +985,17 @@ class IOPTestActivity : AppCompatActivity() { onConnectionFailure: (() -> Unit)? = null ) { Log.d(TAG, "connectToDevice() with param called with: bluetoothDevice = $bluetoothDevice") + bluetoothDevice?.address?.let { + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_connecting, + activeExpertTestId(), + it + ), + tone = "info" + ) + } mStartTimeConnection = System.currentTimeMillis() // Save the callback functions @@ -863,6 +1022,17 @@ class IOPTestActivity : AppCompatActivity() { private fun connectToDevice(bluetoothDevice: BluetoothDevice?) { Log.d(TAG, "connectToDevice() called with: bluetoothDevice = $bluetoothDevice") + bluetoothDevice?.address?.let { + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_connecting, + activeExpertTestId(), + it + ), + tone = "info" + ) + } mStartTimeConnection = System.currentTimeMillis() Log.d(TAG, "connectToDevice(), postDelayed connectionRunnable") @@ -893,12 +1063,21 @@ class IOPTestActivity : AppCompatActivity() { isTestRunning = true isTestFinished = false bondingRemovedDialogShownThisRun = false + endOfTestCleanupPerformed = false // Drop pending work from any previous run (delays, retries, scan timeout). + cancelExpertLogFlush() handler?.removeCallbacksAndMessages(null) reconnectTimer?.cancel() reconnectTimer = Timer() + clearExpertLog() + postExpertLog( + category = "RUN", + title = getString(R.string.iop_expert_log_test_started), + tone = "session" + ) + resetFunctionTest() removeBondIfBondedForDeviceUnderTest() startItemTest(POSITION_TEST_SCANNER) @@ -910,6 +1089,7 @@ class IOPTestActivity : AppCompatActivity() { * Update Ui footer */ private fun updateUIFooter(isRunning: Boolean) { + updateNavigationButtonStates() if (!isRunning) { binding.btnStartAndStopTest.apply { text = getString(R.string.button_run_test) @@ -922,10 +1102,7 @@ class IOPTestActivity : AppCompatActivity() { ) } if (isTestFinished) { - shareMenuItem?.isVisible = true - mBluetoothDevice?.let { removeBond(it) } - releaseIopBluetoothResources() - showBondingRemovedDialogIfNeeded() + performEndOfTestCleanup() } } else { binding.btnStartAndStopTest?.apply { @@ -954,13 +1131,14 @@ class IOPTestActivity : AppCompatActivity() { * Clears an existing pairing so the IOP run can perform a fresh bonding flow. * Uses the last known device and, if set, the address in the system bonded list. */ - private fun removeBondIfBondedForDeviceUnderTest() { - removeBondIfBonded(mBluetoothDevice) + private fun removeBondIfBondedForDeviceUnderTest(): Boolean { + var removed = removeBondIfBonded(mBluetoothDevice) mDeviceAddress?.let { addr -> bluetoothAdapter.bondedDevices .find { it.address.equals(addr, ignoreCase = true) } - ?.let { removeBondIfBonded(it) } + ?.let { removed = removeBondIfBonded(it) || removed } } + return removed } private fun removeBondIfBonded(device: BluetoothDevice?): Boolean { @@ -972,6 +1150,250 @@ class IOPTestActivity : AppCompatActivity() { return invoked } + /** + * After IOP Security control writes 0x02 (authentication) or 0x03 (bonding), wait for the + * firmware to drop the link before clearing the phone bond and reconnecting. + */ + private fun removeBondAfterSecurityControlWrite(securityControlByte: Int) { + Log.d( + TAG, + "removeBondAfterSecurityControlWrite: control=0x${securityControlByte.toString(16)} index=$iopPhase3IndexStartChildrenTest" + ) + securityPendingControlByte = securityControlByte + securityPendingReadIndex = iopPhase3IndexStartChildrenTest + securityAwaitingReadAfterBondRemove = true + securityWaitingForFirmwareDisconnect = true + resetSecurityChildReadState(securityPendingReadIndex) + mBluetoothDevice = mBluetoothDevice ?: mBluetoothGatt?.device + isConnecting = true + handler?.removeCallbacks(securityAfterBondRemoveRunnable) + handler?.removeCallbacks(securityFirmwareDisconnectTimeoutRunnable) + scheduleSecurityFirmwareDisconnectTimeout() + Log.d( + TAG, + "removeBondAfterSecurityControlWrite: waiting for firmware disconnect before bond removal" + ) + } + + private val securityFirmwareDisconnectTimeoutRunnable = Runnable { + if (!securityWaitingForFirmwareDisconnect || !securityAwaitingReadAfterBondRemove) { + return@Runnable + } + Log.w(TAG, "security firmware disconnect timeout; forcing local disconnect") + securityWaitingForFirmwareDisconnect = false + isConnecting = true + mBluetoothDevice = mBluetoothDevice ?: mBluetoothGatt?.device + try { + mBluetoothGatt?.disconnect() + } catch (_: Exception) { + } + handler?.postDelayed({ onSecurityLinkDownProceedWithBondRemove() }, 500L) + } + + private fun scheduleSecurityFirmwareDisconnectTimeout() { + handler?.removeCallbacks(securityFirmwareDisconnectTimeoutRunnable) + handler?.postDelayed( + securityFirmwareDisconnectTimeoutRunnable, + SECURITY_FIRMWARE_DISCONNECT_TIMEOUT_MS + ) + } + + /** + * Link is down after a security control write; now safe to clear the phone bond off-link. + */ + private fun onSecurityLinkDownProceedWithBondRemove() { + if (!securityAwaitingReadAfterBondRemove + || isTestFinished + || !isTestRunning + || endOfTestCleanupPerformed + || mIndexRunning != POSITION_TEST_IOP3_SECURITY + ) { + return + } + isConnecting = true + mBluetoothDevice = mBluetoothDevice ?: mBluetoothGatt?.device + val bondRemoved = removeBondIfBondedForDeviceUnderTest() + val device = mBluetoothDevice + ?: mDeviceAddress?.let { addr -> + bluetoothAdapter.bondedDevices + ?.find { it.address.equals(addr, ignoreCase = true) } + } + if (!bondRemoved || device?.bondState == BluetoothDevice.BOND_NONE) { + scheduleSecurityAfterBondRemove(BOND_REMOVE_DELAY_MS) + } else { + // Fallback if BOND_NONE broadcast is delayed on some OEM stacks (e.g. Samsung). + scheduleSecurityAfterBondRemove(BOND_REMOVE_DELAY_MS * 3) + } + } + + private val securityAfterBondRemoveRunnable = Runnable { + continueSecurityAfterBondRemove() + } + + private fun scheduleSecurityAfterBondRemove(delayMs: Long) { + if (isTestFinished || !isTestRunning || endOfTestCleanupPerformed) { + return + } + handler?.removeCallbacks(securityAfterBondRemoveRunnable) + handler?.postDelayed(securityAfterBondRemoveRunnable, delayMs) + } + + private fun clearSecurityBondRemovePending() { + securityAwaitingReadAfterBondRemove = false + securityWaitingForFirmwareDisconnect = false + securityPendingReadIndex = -1 + securityPendingControlByte = -1 + handler?.removeCallbacks(securityAfterBondRemoveRunnable) + handler?.removeCallbacks(securityFirmwareDisconnectTimeoutRunnable) + } + + /** + * Cancels delayed security / discovery work so end-of-run bond removal does not reconnect or re-pair. + */ + private fun cancelPendingIopBluetoothWork() { + clearSecurityBondRemovePending() + bondedDiscoveryRetryToken++ + gattDiscoveryInProgress = false + handler?.removeCallbacks(iopSecurityRunnable) + handler?.removeCallbacks(connectionRunnable) + isConnecting = false + reconnectTimer?.cancel() + reconnectTimer = Timer() + } + + /** + * Idempotent teardown after a full IOP run. Cancels stale reconnect/discovery work before bond removal. + */ + private fun performEndOfTestCleanup() { + if (endOfTestCleanupPerformed || !isTestFinished) { + return + } + endOfTestCleanupPerformed = true + shareMenuItem?.isVisible = true + cancelPendingIopBluetoothWork() + val bondRemoved = removeBondIfBondedForDeviceUnderTest() + Log.d(TAG, "performEndOfTestCleanup: bondRemoved=$bondRemoved") + val releaseDelayMs = if (bondRemoved) BOND_REMOVE_DELAY_MS else 0L + handler?.postDelayed({ + if (isFinishing) { + return@postDelayed + } + releaseIopBluetoothResources() + showBondingRemovedDialogIfNeeded() + }, releaseDelayMs) + } + + private fun resetSecurityChildReadState(index: Int) { + getListChildrenItemTestCase(POSITION_TEST_IOP3_SECURITY)?.getOrNull(index)?.apply { + isReadCharacteristic = false + isWriteCharacteristic = false + statusRead = -1 + statusWrite = -1 + statusRunTest = 0 + } + } + + private fun continueSecurityAfterBondRemove() { + if (!securityAwaitingReadAfterBondRemove + || isFinishing + || isTestFinished + || endOfTestCleanupPerformed + || !isTestRunning + || mIndexRunning != POSITION_TEST_IOP3_SECURITY + ) { + clearSecurityBondRemovePending() + return + } + handler?.removeCallbacks(securityAfterBondRemoveRunnable) + val index = securityPendingReadIndex + val controlByte = securityPendingControlByte + if (index < 0) { + clearSecurityBondRemovePending() + return + } + iopPhase3IndexStartChildrenTest = index + resetSecurityChildReadState(index) + mBluetoothDevice = mBluetoothDevice ?: mBluetoothGatt?.device + mListCharacteristics.clear() + characteristicsPhase3Security.clear() + characteristicIOPPhase3Control = null + + Log.d( + TAG, + "continueSecurityAfterBondRemove: reconnect after security control 0x" + + controlByte.toString(16) + ) + isConnecting = true + reconnect(BOND_REMOVE_DELAY_MS + BOND_RECONNECT_EXTRA_DELAY_MS) + } + + private fun securityControlByteFromControlWrite(value: ByteArray?): Int? { + if (value == null || value.size < 2) { + return null + } + return value[1].toInt() and 0xFF + } + + /** + * Starts GATT service discovery when the link is up. Skips if a discovery is already running. + */ + private fun startGattServiceDiscovery(source: String): Boolean { + if (isTestFinished || !isTestRunning) { + return false + } + val gatt = mBluetoothGatt + if (gatt == null || !isConnected) { + Log.w(TAG, "startGattServiceDiscovery($source): gatt not ready (connected=$isConnected)") + return false + } + if (gattDiscoveryInProgress) { + Log.d(TAG, "startGattServiceDiscovery($source): already in progress") + return true + } + val started = gatt.discoverServices() + if (started) { + gattDiscoveryInProgress = true + Log.d(TAG, "startGattServiceDiscovery($source): started") + } else { + Log.e(TAG, "startGattServiceDiscovery($source): failed to start") + } + return started + } + + private fun scheduleBondedServiceDiscoveryRetry(attempt: Int = 1) { + if (isTestFinished || !isTestRunning) { + return + } + if (attempt > 6) { + Log.e(TAG, "scheduleBondedServiceDiscoveryRetry: giving up after $attempt attempts") + if (!isTestFinished + && isTestRunning + && mIndexRunning == POSITION_TEST_IOP3_SECURITY + ) { + isConnecting = true + reconnect(BOND_REMOVE_DELAY_MS + BOND_RECONNECT_EXTRA_DELAY_MS) + } else if (!isTestFinished && isTestRunning) { + retryIOP3Failed(mIndexRunning, ++countReTest / 2) + } + return + } + val retryToken = bondedDiscoveryRetryToken + handler?.postDelayed({ + if (retryToken != bondedDiscoveryRetryToken || isTestFinished || !isTestRunning) { + return@postDelayed + } + if (getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_SECURITY].getStatusTest() != Common.IOP3_TC_STATUS_PROCESSING + && getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_LE_PRIVACY].getStatusTest() != Common.IOP3_TC_STATUS_PROCESSING + ) { + return@postDelayed + } + if (startGattServiceDiscovery("bondStateBonded-retry$attempt")) { + return@postDelayed + } + scheduleBondedServiceDiscoveryRetry(attempt + 1) + }, BOND_DISCOVERY_RETRY_STEP_MS * attempt) + } + private fun showBondingRemovedDialogIfNeeded() { try { if (bondingRemovedDialogShownThisRun || isFinishing) { @@ -1028,6 +1450,7 @@ class IOPTestActivity : AppCompatActivity() { countReTest = 0 iopPhase3IndexStartChildrenTest = -1 iopPhase3BondingStep = 2 + clearSecurityBondRemovePending() iopSecurityIntroAcknowledged = false iopPhase3ExtraDescriptor = null iopPhase3DatabaseHash = null @@ -1042,6 +1465,7 @@ class IOPTestActivity : AppCompatActivity() { mByteNumReceived = 0 mPDULength = 0 mByteSpeed = 0 + mPeakBitsPerSec = 0 mEndThroughputNotification = false read_CCCD_value = ByteArray(1) @@ -1076,9 +1500,15 @@ class IOPTestActivity : AppCompatActivity() { mtu = 247 currentRxPhy = null mtuDivisible = 0 + otaPacketSizeWithAck = 0 + otaPacketSizeWithoutAck = 0 + currentOtaPacketSize = 0 + expertLoggedChildResults.clear() isServiceChangedIndication = 1 isConnecting = false + gattDiscoveryInProgress = false + isDisabled = false onConnectionSuccess = null @@ -1108,6 +1538,7 @@ class IOPTestActivity : AppCompatActivity() { Log.d("onDestroy", "scanLeDevice(false)") } + dismissThroughputSpeedDialog() handler?.removeCallbacksAndMessages(null) reconnectTimer = null handler = null @@ -1128,6 +1559,329 @@ class IOPTestActivity : AppCompatActivity() { mListener = listener } + fun setExpertListener(listener: IOPExpertListener) { + mExpertListener = listener + notifyExpertLogUi() + } + + private fun isExpertMode(): Boolean = isExpertTabSelected + + private fun cancelExpertLogFlush() { + expertLogFlushRunnable?.let { handler?.removeCallbacks(it) } + expertLogFlushRunnable = null + } + + private fun postExpertLog( + category: String, + title: String, + detail: String? = null, + tone: String = "info" + ) { + val entry = IOPExpertLogEntry( + timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()), + category = category, + title = title, + detail = detail, + tone = tone + ) + val enqueue = Runnable { + synchronized(expertLogLock) { + pendingExpertLogEntries.add(entry) + } + scheduleExpertLogFlush() + } + if (Looper.myLooper() == Looper.getMainLooper()) { + enqueue.run() + } else { + handler?.post(enqueue) ?: enqueue.run() + } + } + + private fun scheduleExpertLogFlush() { + if (expertLogFlushRunnable != null) return + expertLogFlushRunnable = Runnable { + expertLogFlushRunnable = null + flushPendingExpertLogEntries() + }.also { + handler?.postDelayed(it, 80L) + } + } + + private fun flushPendingExpertLogEntries() { + val batch = synchronized(expertLogLock) { + if (pendingExpertLogEntries.isEmpty()) { + return + } + pendingExpertLogEntries.toList().also { pendingExpertLogEntries.clear() } + } + synchronized(expertLogLock) { + for (entry in batch) { + val last = expertLogEntries.lastOrNull() + if (last != null && last.canCollapseWith(entry)) { + last.repeatCount += 1 + last.timestamp = entry.timestamp + } else { + expertLogEntries.add(entry) + } + } + } + notifyExpertLogUi() + } + + private fun notifyExpertLogUi() { + val snapshot = synchronized(expertLogLock) { expertLogEntries.toList() } + runOnUiThread { + val listener = mExpertListener + ?: findExpertFragment()?.also { mExpertListener = it } + ?: expertFragment?.takeIf { it.isAdded }?.also { mExpertListener = it } + listener?.restoreLog(snapshot) + } + } + + private fun clearExpertLog() { + cancelExpertLogFlush() + expertLoggedChildResults.clear() + synchronized(expertLogLock) { + pendingExpertLogEntries.clear() + expertLogEntries.clear() + } + runOnUiThread { + mExpertListener?.clearLog() + expertFragment?.takeIf { it.isAdded }?.clearLog() + findExpertFragment()?.clearLog() + } + } + + private fun formatCharacteristicValue(bytes: ByteArray?): String { + if (bytes == null || bytes.isEmpty()) return "empty" + return bytes.joinToString(" ") { String.format(Locale.US, "%02X", it) } + } + + private fun packetSizeOf(bytes: ByteArray?): Int = bytes?.size ?: 0 + + private fun formatScenarioTestId(itemTestCaseInfo: ItemTestCaseInfo): String { + return when { + itemTestCaseInfo.idTest < 5 -> itemTestCaseInfo.idTest.toString() + itemTestCaseInfo.idTest == 7 -> "7.1" + itemTestCaseInfo.idTest == 5 -> "6.1" + itemTestCaseInfo.idTest == 6 -> "6.2" + itemTestCaseInfo.idTest == 9 -> "7.6" + else -> (itemTestCaseInfo.idTest + 1).toString() + } + } + + private fun formatChildTestId( + itemTestCaseInfo: ItemTestCaseInfo, + child: ChildrenItemTestInfo + ): String { + val major = when { + child.id >= 11 -> itemTestCaseInfo.idTest + 1 + itemTestCaseInfo.idTest == 8 -> 7 + else -> itemTestCaseInfo.idTest + } + val minor = when { + child.id >= 11 -> child.id - 10 + itemTestCaseInfo.idTest < 6 -> child.id + itemTestCaseInfo.idTest == 8 -> child.id + 1 + else -> child.id + 4 + } + return "$major.$minor" + } + + private fun activeExpertTestId(): String { + if (mIndexRunning < 0) return "—" + val item = getSiliconLabsTestInfo().listItemTest.getOrNull(mIndexRunning) ?: return "—" + return when (mIndexRunning) { + POSITION_TEST_SERVICE -> { + val child = getListChildrenItemTestCase(POSITION_TEST_SERVICE) + ?.getOrNull(mIndexStartChildrenTest) + child?.let { formatChildTestId(item, it) } ?: formatScenarioTestId(item) + } + POSITION_TEST_IOP3_SECURITY -> { + val child = getListChildrenItemTestCase(POSITION_TEST_IOP3_SECURITY) + ?.getOrNull(iopPhase3IndexStartChildrenTest) + child?.let { formatChildTestId(item, it) } ?: formatScenarioTestId(item) + } + else -> formatScenarioTestId(item) + } + } + + private fun logExpertChildTestStart( + itemPosition: Int, + childIndex: Int + ) { + val item = getSiliconLabsTestInfo().listItemTest.getOrNull(itemPosition) ?: return + val child = getListChildrenItemTestCase(itemPosition)?.getOrNull(childIndex) ?: return + val testId = formatChildTestId(item, child) + postExpertLog( + category = "TEST", + title = getString(R.string.iop_expert_log_child_test_start, testId, child.nameTest), + detail = child.properties.takeIf { it.isNotBlank() }, + tone = "test" + ) + } + + private fun logExpertChildTestResultIfNeeded( + itemPosition: Int, + child: ChildrenItemTestInfo + ) { + val item = getSiliconLabsTestInfo().listItemTest.getOrNull(itemPosition) ?: return + val testId = formatChildTestId(item, child) + if (!expertLoggedChildResults.add(testId) || child.statusRunTest != 1) { + return + } + if (child.statusChildrenTest) { + postExpertLog( + category = "PASS", + title = getString(R.string.iop_expert_log_test_pass, testId), + detail = child.nameTest, + tone = "success" + ) + } else { + val errorDetail = child.getValueErrorLog().takeIf { it != "N/A" } + postExpertLog( + category = "FAIL", + title = getString(R.string.iop_expert_log_test_fail, testId), + detail = errorDetail ?: child.nameTest, + tone = "failure" + ) + } + } + + private fun logExpertOtaPacketSize(reliableWrite: Boolean) { + val testId = activeExpertTestId() + val packetSize = if (reliableWrite) { + var minus = 0 + var divisible: Int + do { + divisible = mtu - 3 - minus + minus++ + } while (divisible % 4 != 0) + otaPacketSizeWithAck = divisible + divisible + } else { + val size = (mtu - 3).coerceAtLeast(0) + otaPacketSizeWithoutAck = size + size + } + currentOtaPacketSize = packetSize + val mode = if (reliableWrite) { + getString(R.string.iop_expert_log_ota_ack_mode) + } else { + getString(R.string.iop_expert_log_ota_unack_mode) + } + postExpertLog( + category = "LOG", + title = getString(R.string.iop_expert_log_ota_packet_size, testId, packetSize, mode), + tone = "ota" + ) + } + + private fun logExpertOtaComplete() { + val testId = activeExpertTestId() + postExpertLog( + category = "PASS", + title = getString( + R.string.iop_expert_log_ota_complete, + testId, + currentOtaPacketSize + ), + tone = "success" + ) + if (otaPacketSizeWithAck > 0 && otaPacketSizeWithoutAck > 0) { + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_ota_packet_size_summary, + otaPacketSizeWithAck, + otaPacketSizeWithoutAck, + mtu + ), + tone = "ota" + ) + } + } + + private fun characteristicLabel(characteristic: BluetoothGattCharacteristic?): String { + if (characteristic == null) return "unknown characteristic" + return characteristic.uuid.toString() + } + + private fun logExpertScenarioStart(item: Int) { + val testCase = getSiliconLabsTestInfo().listItemTest.getOrNull(item) ?: return + postExpertLog( + category = "SCENARIO", + title = getString( + R.string.iop_expert_log_scenario_start, + formatScenarioTestId(testCase), + testCase.titlesTest + ), + detail = testCase.describe, + tone = "discovery" + ) + } + + private fun logExpertScenarioResult(item: Int, itemTestCaseInfo: ItemTestCaseInfo) { + val testId = formatScenarioTestId(itemTestCaseInfo) + when (itemTestCaseInfo.getStatusTest()) { + Common.IOP3_TC_STATUS_PASS -> postExpertLog( + category = "PASS", + title = getString(R.string.iop_expert_log_test_pass, testId), + detail = if (item == POSITION_TEST_IOP3_THROUGHPUT) { + itemTestCaseInfo.getThroughputPassedTestcase() + } else { + itemTestCaseInfo.describe + }, + tone = "success" + ) + Common.IOP3_TC_STATUS_FAILED -> { + val detail = when (item) { + POSITION_TEST_IOP3_THROUGHPUT -> itemTestCaseInfo.getThroughputPassedTestcase() + else -> itemTestCaseInfo.describe + } + postExpertLog( + category = "FAIL", + title = getString(R.string.iop_expert_log_test_fail, testId), + detail = detail, + tone = "failure" + ) + } + } + } + + private fun buildExpertLogText(): String { + val snapshot = synchronized(expertLogLock) { expertLogEntries.toList() } + return snapshot.joinToString("\n\n") { entry -> + buildString { + append(entry.timestamp) + if (entry.category.isNotBlank()) { + append(" [") + append(entry.category) + if (entry.repeatCount > 1) append(" x${entry.repeatCount}") + append("]") + } + append("\n") + append(entry.title) + entry.detail?.takeIf { it.isNotBlank() }?.let { + append("\n") + append(it) + } + } + } + } + + private fun launchGattInfo() { + if (isTestRunning) return + val info = getSiliconLabsTestInfo() + IOPGattInfoDialog.newInstance(info.fwName, info.deviceMacAddress) + .show(supportFragmentManager, IOPGattInfoDialog::class.java.simpleName) + } + + private fun updateNavigationButtonStates() { + gattInfoMenuItem?.isVisible = !isTestRunning + gattInfoMenuItem?.isEnabled = !isTestRunning + } + override fun onBackPressed() { if (isTestRunning) { showDialogConfirmStopTest() @@ -1165,7 +1919,7 @@ class IOPTestActivity : AppCompatActivity() { bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner handler = Handler(Looper.getMainLooper()) - addChildrenView() + setupViewPager() showDetailInformationTest(POSITION_TEST_SCANNER, true) checkBluetoothExtendedSettings() registerBroadcastReceivers() @@ -1230,9 +1984,11 @@ class IOPTestActivity : AppCompatActivity() { override fun onCreateOptionsMenu(menu: Menu?): Boolean { menuInflater.inflate(R.menu.menu_iop_test, menu) - shareMenuItem = menu?.get(0)?.also { + gattInfoMenuItem = menu?.findItem(R.id.iop_gatt_info) + shareMenuItem = menu?.findItem(R.id.iop_share)?.also { it.isVisible = false } + updateNavigationButtonStates() return true } @@ -1246,13 +2002,21 @@ class IOPTestActivity : AppCompatActivity() { dialogBinding.tvTestLog.setOnClickListener { isLogcatData = false - //saveLogFile() + isExpertLogData = false + saveLogcatFile() + dialog.dismiss() + } + + dialogBinding.tvExpertLog.setOnClickListener { + isLogcatData = false + isExpertLogData = true saveLogcatFile() dialog.dismiss() } dialogBinding.tvAppLog.setOnClickListener { isLogcatData = true + isExpertLogData = false saveLogcatFile() dialog.dismiss() } @@ -1265,6 +2029,11 @@ class IOPTestActivity : AppCompatActivity() { override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { + R.id.iop_gatt_info -> { + launchGattInfo() + true + } + R.id.iop_share -> { showCustomDialog() true @@ -1324,6 +2093,15 @@ class IOPTestActivity : AppCompatActivity() { if (enable) { if (!isScanning) { isScanning = true + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_scanning, + activeExpertTestId(), + getSiliconLabsTestInfo().fwName + ), + tone = "info" + ) handler?.postDelayed(scanRunnable, SCAN_PERIOD) readScannerStartTime = true Log.d(TAG, "Scanner Fw name: " + getSiliconLabsTestInfo().fwName) @@ -1383,13 +2161,47 @@ class IOPTestActivity : AppCompatActivity() { } /** - * Add Fragment have not yet list item test. + * Add Standard and Expert mode tabs. */ - private fun addChildrenView() { - supportFragmentManager.beginTransaction().apply { - replace(R.id.container, newInstance(), IOPTestFragment::class.java.name) - disallowAddToBackStack() - }.commit() + private fun setupViewPager() { + val pagerAdapter = object : FragmentStateAdapter(this) { + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment = when (position) { + TAB_EXPERT -> newExpertInstance().also { expertFragment = it } + else -> newInstance() + } + } + binding.viewPagerIop.apply { + adapter = pagerAdapter + offscreenPageLimit = 2 + isUserInputEnabled = false + } + TabLayoutMediator(binding.tabLayoutIopMode, binding.viewPagerIop) { tab, position -> + tab.text = when (position) { + TAB_EXPERT -> getString(R.string.iop_tab_expert) + else -> getString(R.string.iop_tab_standard) + } + }.attach() + binding.tabLayoutIopMode.addOnTabSelectedListener(object : + com.google.android.material.tabs.TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: com.google.android.material.tabs.TabLayout.Tab?) { + isExpertTabSelected = tab?.position == TAB_EXPERT + findExpertFragment()?.let { setExpertListener(it) } + } + + override fun onTabUnselected(tab: com.google.android.material.tabs.TabLayout.Tab?) = Unit + override fun onTabReselected(tab: com.google.android.material.tabs.TabLayout.Tab?) = Unit + }) + binding.viewPagerIop.post { + findExpertFragment()?.let { setExpertListener(it) } + } + } + + private fun findExpertFragment(): IOPExpertFragment? { + return expertFragment?.takeIf { it.isAdded } + ?: (supportFragmentManager.findFragmentByTag("f$TAB_EXPERT") as? IOPExpertFragment) + ?: supportFragmentManager.fragments.filterIsInstance().firstOrNull() } override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { @@ -1402,8 +2214,46 @@ class IOPTestActivity : AppCompatActivity() { } GBL_FILE_CHOICE_REQUEST_CODE -> { + val wasExpertOtaAuto = pendingExpertOtaAutoContinue + pendingExpertOtaAutoContinue = false + if (wasExpertOtaAuto && resultCode != Activity.RESULT_OK) { + postExpertLog( + category = "FAIL", + title = getString( + R.string.iop_expert_log_ota_file_cancelled, + activeExpertTestId() + ), + tone = "failure" + ) + checkIOP3OTA(mIndexRunning, Common.IOP3_TC_STATUS_FAILED) + finishItemTest( + mIndexRunning, + getSiliconLabsTestInfo().listItemTest[mIndexRunning] + ) + return + } intent?.data?.let { otaFileManager?.readFilename(it) + if (wasExpertOtaAuto) { + if (otaFileManager?.hasCorrectFileExtension() == true) { + otaFileManager?.readFile(it) + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_ota_file_selected, + activeExpertTestId(), + otaFileManager?.otaFilename ?: it.lastPathSegment.orEmpty() + ), + tone = "info" + ) + startOtaProcess() + } else { + CustomToastManager.show( + this@IOPTestActivity, getString(R.string.incorrect_file), 5000 + ) + } + return + } otaFileSelectionDialog?.changeFileName(otaFileManager?.otaFilename) if (otaFileManager?.hasCorrectFileExtension() == true) { otaFileManager?.readFile(it) @@ -1459,6 +2309,8 @@ class IOPTestActivity : AppCompatActivity() { OutputStreamWriter(fOut).use { myOutWriter -> if (isLogcatData) { myOutWriter.write(logs + "\n" + getDataLog()) + } else if (isExpertLogData) { + myOutWriter.write(buildExpertLogText()) } else { myOutWriter.write(getDataLog()) } @@ -1530,6 +2382,17 @@ class IOPTestActivity : AppCompatActivity() { } characteristic.value = newValue Log.d(TAG, "writeValueToCharacteristic " + characteristic.uuid.toString()) + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_writing, + activeExpertTestId(), + characteristic.uuid.toString(), + formatCharacteristicValue(newValue), + packetSizeOf(newValue) + ), + tone = "info" + ) // Perform the write operation asynchronously val success = mBluetoothGatt?.writeCharacteristic(characteristic) ?: false @@ -1567,6 +2430,17 @@ class IOPTestActivity : AppCompatActivity() { } characteristic.value = newValue Log.d(TAG, "writeValueToCharacteristic " + characteristic.uuid.toString()) + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_writing, + activeExpertTestId(), + characteristic.uuid.toString(), + formatCharacteristicValue(newValue), + packetSizeOf(newValue) + ), + tone = "info" + ) if (!mBluetoothGatt!!.writeCharacteristic(characteristic)) { Log.e( TAG, @@ -1593,6 +2467,15 @@ class IOPTestActivity : AppCompatActivity() { * Read values by characteristic */ private fun readCharacteristic(characteristic: BluetoothGattCharacteristic?) { + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_reading, + activeExpertTestId(), + characteristicLabel(characteristic) + ), + tone = "info" + ) mBluetoothGatt?.readCharacteristic(characteristic) } @@ -1603,6 +2486,15 @@ class IOPTestActivity : AppCompatActivity() { mEndTimeDiscover = System.currentTimeMillis() val gattServices = gatt.services Log.d(TAG, "getServicesInfo(), Services count: " + gattServices.size) + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_discovered_services, + activeExpertTestId(), + gattServices.size + ), + tone = "info" + ) var count = 0 for (gattService: BluetoothGattService in gattServices) { val serviceUUID = gattService.uuid.toString() @@ -1771,14 +2663,6 @@ class IOPTestActivity : AppCompatActivity() { } } - POSITION_TEST_IOP3_THROUGHPUT -> { - mEndThroughputNotification = false - finishItemTest( - POSITION_TEST_IOP3_THROUGHPUT, - getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_THROUGHPUT] - ) - } - POSITION_TEST_IOP3_LE_PRIVACY -> { return /* finishItemTest( @@ -1789,7 +2673,12 @@ class IOPTestActivity : AppCompatActivity() { POSITION_TEST_IOP3_SECURITY -> { if (iopPhase3BondingStep == 2) { + if (securityPendingReadIndex >= 0) { + iopPhase3IndexStartChildrenTest = securityPendingReadIndex + } iopPhase3RunTestCaseSecurity(iopPhase3IndexStartChildrenTest, 0) + clearSecurityBondRemovePending() + isConnecting = false } else { iopPhase3RunTestCaseBonding(6) } @@ -1924,10 +2813,11 @@ class IOPTestActivity : AppCompatActivity() { pathFile.replace(" ", "") if (isLogcatData) { return "logcat" + "_" + getDate("EEE MMM dd HH_mm_ss z yyyy") + ".txt" - - } else { - return pathFile + "_" + boardName + "_" + getDate("yyyy_MM_dd_HH_mm_ss") + ".txt" } + if (isExpertLogData) { + return "expert_log_" + getDate("yyyy_MM_dd_HH_mm_ss") + ".txt" + } + return pathFile + "_" + boardName + "_" + getDate("yyyy_MM_dd_HH_mm_ss") + ".txt" } @@ -1952,6 +2842,8 @@ class IOPTestActivity : AppCompatActivity() { type = "text/plain" if (isLogcatData) { putExtra(Intent.EXTRA_SUBJECT, "[Silabs] Application Debug log") + } else if (isExpertLogData) { + putExtra(Intent.EXTRA_SUBJECT, "[Silabs] IOP Expert Step Log") } else { putExtra(Intent.EXTRA_SUBJECT, "[Silabs] Test log") } @@ -1982,10 +2874,10 @@ class IOPTestActivity : AppCompatActivity() { if (mIndexStartChildrenTest <= 17) { mIndexStartChildrenTest += 1 runChildrenTestCase(mIndexStartChildrenTest) - } else { - return } + return } + logExpertChildTestStart(POSITION_TEST_SERVICE, index) var matchChar = -1 for (i in uuids.indices) { if (cUuid.equals(uuids[i].toString(), ignoreCase = true)) { @@ -2384,6 +3276,7 @@ class IOPTestActivity : AppCompatActivity() { private fun iopPhase3RunTestCaseSecurity(index: Int, isControl: Int) { iopPhase3IndexStartChildrenTest = index isConnecting = false + logExpertChildTestStart(POSITION_TEST_IOP3_SECURITY, index) val securityItem = getListChildrenItemTestCase(POSITION_TEST_IOP3_SECURITY)!![index] val cUuid = securityItem.characteristic?.uuid.toString() val uuids = CommonUUID.Characteristic.values() @@ -2431,6 +3324,186 @@ class IOPTestActivity : AppCompatActivity() { } + /** + * IOP throughput gauge max is 1.5 Mbit/s; speed is in bit/s. + */ + private fun iopThroughputProgressForSpeed(speedBitsPerSec: Int): Int { + return ((speedBitsPerSec / 1_500_000.0) * 100).toInt().coerceIn(0, 100) + } + + private fun iopThroughputSpeedAsString(speedBitsPerSec: Int): String { + return if (speedBitsPerSec >= 1000000) { + String.format(Locale.US, "%.1f", speedBitsPerSec / 1000000.0) + } else { + String.format(Locale.US, "%.1f", speedBitsPerSec / 1000.0) + } + } + + private fun iopThroughputUnitAsString(speedBitsPerSec: Int): String { + return if (speedBitsPerSec >= 1000000) "Mbps" else "kbps" + } + + /** + * Recompute byte/s from totals so Done uses the same data the gauge displayed. + */ + private fun recalculateFinalThroughputByteSpeed() { + if (mStartTimeThroughput <= 0) return + val elapsedMs = when { + mEndTimeThroughput > mStartTimeThroughput -> mEndTimeThroughput - mStartTimeThroughput + else -> System.currentTimeMillis() - mStartTimeThroughput + }.coerceAtLeast(1L) + mByteSpeed = ((mByteNumReceived * 1000L) / elapsedMs).toInt() + Log.d( + TAG, + "recalculateFinalThroughputByteSpeed mByteNumReceived=$mByteNumReceived elapsedMs=$elapsedMs mByteSpeed=$mByteSpeed" + ) + updateThroughputSummaryLabels() + } + + /** + * MTU: negotiated ATT MTU. Buffer: last notification payload size if seen, else max ATT payload (MTU − 3). + */ + private fun updateThroughputDialogMtuBufferLabels() { + throughputDialogMtuText?.text = + getString(R.string.iop_throughput_dialog_mtu_size, mtu) + val bufferBytes = + if (mPDULength > 0) mPDULength else (mtu - 3).coerceAtLeast(0) + throughputDialogBufferText?.text = + getString(R.string.iop_throughput_dialog_buffer_size, bufferBytes) + } + + private fun setThroughputMetricLabel(textView: TextView?, stringRes: Int, bitsPerSec: Int) { + textView?.apply { + visibility = View.VISIBLE + text = getString( + stringRes, + iopThroughputSpeedAsString(bitsPerSec), + iopThroughputUnitAsString(bitsPerSec) + ) + } + } + + private fun updateThroughputThresholdLabel() { + val bitsPerSec = calculateAcceptableThroughput() * 8 + if (bitsPerSec <= 0) { + throughputDialogThresholdText?.visibility = View.GONE + return + } + setThroughputMetricLabel( + throughputDialogThresholdText, + R.string.iop_throughput_dialog_threshold, + bitsPerSec + ) + } + + private fun updateThroughputSummaryLabels() { + if (mEndTimeThroughput <= mStartTimeThroughput) { + throughputDialogPeakText?.visibility = View.GONE + throughputDialogAverageText?.visibility = View.GONE + return + } + setThroughputMetricLabel( + throughputDialogPeakText, + R.string.iop_throughput_dialog_peak, + mPeakBitsPerSec + ) + setThroughputMetricLabel( + throughputDialogAverageText, + R.string.iop_throughput_dialog_average, + mByteSpeed * 8 + ) + } + + private fun showThroughputSpeedDialog() { + if (isFinishing || isDestroyed) return + dismissThroughputSpeedDialog() + val content = LayoutInflater.from(this).inflate(R.layout.dialog_iop_throughput_speed, null) + val speedView = content.findViewById(R.id.speed_view) + speedView.setUnitsArray( + arrayListOf( + "0", + "250kbps", + "500kbps", + "750kbps", + "1Mbit", + "1.25Mbit", + "1.5Mbit" + ) + ) + throughputSpeedView = speedView + throughputDialogMtuText = content.findViewById(R.id.tv_iop_throughput_mtu) + throughputDialogBufferText = content.findViewById(R.id.tv_iop_throughput_buffer) + throughputDialogPeakText = content.findViewById(R.id.tv_iop_throughput_peak) + throughputDialogAverageText = content.findViewById(R.id.tv_iop_throughput_average) + throughputDialogThresholdText = content.findViewById(R.id.tv_iop_throughput_threshold) + throughputDialogPeakText?.visibility = View.GONE + throughputDialogAverageText?.visibility = View.GONE + pendingThroughputDescriptorStatus = null + throughputDialogDoneButton = content.findViewById(R.id.btn_iop_throughput_done) + throughputDialogDoneButton?.setOnClickListener { onThroughputSpeedDialogDoneClicked() } + updateThroughputDialogMtuBufferLabels() + updateThroughputThresholdLabel() + throughputSpeedDialog = AlertDialog.Builder(this) + .setView(content) + .setCancelable(false) + .create() + throughputSpeedDialog?.window?.apply { + val maxWidth = resources.getDimensionPixelSize(R.dimen.iop_throughput_dialog_max_width) + val screenWidth = (resources.displayMetrics.widthPixels * 0.88f).toInt() + setLayout( + minOf(screenWidth, maxWidth), + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setBackgroundDrawableResource(android.R.color.transparent) + } + throughputSpeedDialog?.show() + isThroughputMeterActive = true + handler?.post(throughputMeterUpdateRunnable) + } + + private fun startThroughputDialogAutoDismissTimer() { + handler?.removeCallbacks(throughputDialogAutoDismissRunnable) + handler?.postDelayed(throughputDialogAutoDismissRunnable, THROUGHPUT_DIALOG_AUTO_DISMISS_MS) + } + + private fun dismissThroughputSpeedDialog() { + isThroughputMeterActive = false + handler?.removeCallbacks(throughputMeterUpdateRunnable) + handler?.removeCallbacks(throughputDialogAutoDismissRunnable) + throughputSpeedView = null + throughputDialogMtuText = null + throughputDialogBufferText = null + throughputDialogPeakText = null + throughputDialogAverageText = null + throughputDialogThresholdText = null + throughputDialogDoneButton = null + pendingThroughputDescriptorStatus = null + if (throughputSpeedDialog?.isShowing == true) { + throughputSpeedDialog?.dismiss() + } + throughputSpeedDialog = null + } + + /** + * Continues the IOP test after the user clicks Done (or immediately when the + * dialog was already dismissed by a failure/destroy path). + */ + private fun continueIopTestAfterThroughputDescriptorWrite(status: Int) { + dismissThroughputSpeedDialog() + throughputDescriptorWriteStatus = status + finishItemTest( + POSITION_TEST_IOP3_THROUGHPUT, + getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_THROUGHPUT] + ) + throughputDescriptorWriteStatus = 0 + } + + private fun onThroughputSpeedDialogDoneClicked() { + val status = pendingThroughputDescriptorStatus ?: return + pendingThroughputDescriptorStatus = null + continueIopTestAfterThroughputDescriptorWrite(status) + } + private fun iopPhase3RunTestCaseThroughput(isControl: Int) { Log.d( TAG, @@ -2446,17 +3519,34 @@ class IOPTestActivity : AppCompatActivity() { } else { Log.d(TAG, "set Notification enable for Throughput") mEndThroughputNotification = false + mByteNumReceived = 0 + mPDULength = 0 + mPeakBitsPerSec = 0 setNotificationForCharacteristic( characteristicIOPPhase3Throughput, Notifications.NOTIFY ) mStartTimeThroughput = System.currentTimeMillis() + runOnUiThread { showThroughputSpeedDialog() } handler?.postDelayed({ mEndTimeThroughput = System.currentTimeMillis() mByteSpeed = ((mByteNumReceived * 1000) / (mEndTimeThroughput - mStartTimeThroughput)).toInt() Log.d(TAG, "set Notification disable for throughput") Log.d(TAG, "Throughput is $mByteSpeed Bytes/sec") + isThroughputMeterActive = false + handler?.removeCallbacks(throughputMeterUpdateRunnable) + val finalBitsPerSec = mByteSpeed * 8 + runOnUiThread { + throughputSpeedView?.updateSpeed( + iopThroughputProgressForSpeed(finalBitsPerSec), + iopThroughputSpeedAsString(finalBitsPerSec), + iopThroughputUnitAsString(finalBitsPerSec), + SpeedView.Mode.DOWNLOAD + ) + updateThroughputDialogMtuBufferLabels() + updateThroughputSummaryLabels() + } disableThroughputNotificationWithRetries() }, 5000) } @@ -2750,6 +3840,12 @@ class IOPTestActivity : AppCompatActivity() { private fun startOtaTestCase(index: Int) { Log.d(TAG, "startOtaTestCase $index") + val otaTestId = if (index == 0) "6.1" else "6.2" + postExpertLog( + category = "TEST", + title = getString(R.string.iop_expert_log_ota_start, otaTestId), + tone = "test" + ) initOtaProgressDialog() otaLoadingDialog = OtaLoadingDialog(getString(R.string.iop_test_label_resetting)) @@ -2760,6 +3856,14 @@ class IOPTestActivity : AppCompatActivity() { when (getSiliconLabsTestInfo().firmwareVersion) { "3.2.1", "3.2.2", "3.2.3", "3.2.4" -> { + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_ota_auto_file, + if (index == 0) "6.1" else "6.2" + ), + tone = "info" + ) otaFileManager ?.apply { uploadMode = OtaFileManager.UploadMode.AUTO } ?.also { @@ -2774,10 +3878,33 @@ class IOPTestActivity : AppCompatActivity() { else -> { otaFileManager?.uploadMode = OtaFileManager.UploadMode.USER - otaFileSelectionDialog = - OtaFileSelectionDialog(listener = fileSelectionListener).also { - it.show(supportFragmentManager, "ota_file_selection_dialog") - } + if (isExpertMode()) { + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_ota_pick_file, + if (index == 0) "6.1" else "6.2" + ), + tone = "info" + ) + pendingExpertOtaAutoContinue = true + Intent(Intent.ACTION_GET_CONTENT) + .apply { type = "*/*" } + .also { + startActivityForResult( + Intent.createChooser( + it, + getString(R.string.ota_choose_file) + ), + GBL_FILE_CHOICE_REQUEST_CODE + ) + } + } else { + otaFileSelectionDialog = + OtaFileSelectionDialog(listener = fileSelectionListener).also { + it.show(supportFragmentManager, "ota_file_selection_dialog") + } + } } } @@ -2899,6 +4026,7 @@ class IOPTestActivity : AppCompatActivity() { Log.d("Instance ID", "" + charac.instanceId) pack = 0 + logExpertOtaPacketSize(reliable) //Set info into UI OTA Progress runOnUiThread { @@ -2925,6 +4053,7 @@ class IOPTestActivity : AppCompatActivity() { Log.d(TAG, "OTAEND Called") ota_alreadyIn_Progress = false isInAppOTA = false + logExpertOtaComplete() handler?.postDelayed({ writeOtaControl(0x03.toByte()) }, 1000) } @@ -2992,12 +4121,17 @@ class IOPTestActivity : AppCompatActivity() { */ private fun refreshServices() { if (!isInAppOTA) { - if (mBluetoothGatt != null && mBluetoothGatt?.device != null) { + if (mBluetoothGatt != null && mBluetoothGatt?.device != null && isConnected) { refreshDeviceCache() - mBluetoothGatt?.discoverServices() + if (!startGattServiceDiscovery("refreshServices")) { + scheduleBondedServiceDiscoveryRetry() + } } else if (mBluetoothService != null && mBluetoothService?.connectedGatt != null) { + mBluetoothGatt = mBluetoothService?.connectedGatt refreshDeviceCache() - mBluetoothService?.connectedGatt?.discoverServices() + if (!startGattServiceDiscovery("refreshServices-viaService")) { + scheduleBondedServiceDiscoveryRetry() + } } } } @@ -3073,6 +4207,8 @@ class IOPTestActivity : AppCompatActivity() { mtuDivisible = mtu - 3 - minus minus++ } while (mtuDivisible % 4 != 0) + otaPacketSizeWithAck = mtuDivisible + currentOtaPacketSize = mtuDivisible } val writearray: ByteArray val pgss: Float @@ -3141,13 +4277,16 @@ class IOPTestActivity : AppCompatActivity() { @Synchronized fun writeOtaData(dataThread: ByteArray?) { try { - val value = ByteArray(mtu - 3) + val packetSize = (mtu - 3).coerceAtLeast(0) + otaPacketSizeWithoutAck = packetSize + currentOtaPacketSize = packetSize + val value = ByteArray(packetSize) val start = System.nanoTime() var j = 0 for (i in dataThread!!.indices) { value[j] = dataThread[i] j++ - if (j >= mtu - 3 || i >= (dataThread.size - 1)) { + if (j >= packetSize || i >= (dataThread.size - 1)) { var wait = System.nanoTime() val charac = mBluetoothGatt?.getService(ota_service)?.getCharacteristic(ota_data) @@ -3155,7 +4294,7 @@ class IOPTestActivity : AppCompatActivity() { val progress = ((i + 1).toFloat() / dataThread.size) * 100 val bitrate = (((i + 1) * (8.0)).toFloat() / (((wait - start) / 1000000.0).toFloat())) - if (j < mtu - 3) { + if (j < packetSize) { val end = ByteArray(j) System.arraycopy(value, 0, end, 0, j) Log.d( @@ -3297,9 +4436,17 @@ class IOPTestActivity : AppCompatActivity() { when (newState) { BluetoothGatt.STATE_CONNECTED -> { Log.d(TAG, "onConnectionStateChange connected") + postExpertLog( + category = "LOG", + title = getString(R.string.iop_expert_log_connected, activeExpertTestId()), + tone = "success" + ) + mBluetoothGatt = gatt handler?.removeCallbacks(connectionRunnable) isConnected = true - isTestFinished = false + if (isTestRunning) { + isTestFinished = false + } if (getSiliconLabsTestInfo().listItemTest[POSITION_TEST_CONNECTION].getStatusTest() == Common.IOP3_TC_STATUS_PROCESSING) { Log.d(TAG, "onConnectionStateChange POSITION_TEST_CONNECTION") finishItemTest( @@ -3313,22 +4460,51 @@ class IOPTestActivity : AppCompatActivity() { "onConnectionStateChange connected mIndexRunning $mIndexRunning" ) mBluetoothGatt?.requestMtu(247) - handler?.postDelayed({ - if (isConnected && mIndexRunning == 8) { + if (securityAwaitingReadAfterBondRemove + && !securityWaitingForFirmwareDisconnect + && mIndexRunning == POSITION_TEST_IOP3_SECURITY + && isTestRunning + && !isTestFinished + && gatt.device.bondState == BluetoothDevice.BOND_BONDED + ) { + handler?.postDelayed({ + if (isTestFinished || !isTestRunning || !securityAwaitingReadAfterBondRemove) { + return@postDelayed + } + Log.d(TAG, "security reconnect: device already bonded, discovering services") + if (!startGattServiceDiscovery("securityReconnectAlreadyBonded")) { + scheduleBondedServiceDiscoveryRetry() + } + }, BOND_COMPLETE_DISCOVERY_DELAY_MS) + } + if (mIndexRunning == POSITION_TEST_IOP3_LE_PRIVACY + && isTestRunning + && !isTestFinished + && !endOfTestCleanupPerformed + ) { + handler?.postDelayed({ + if (!isTestRunning + || isTestFinished + || endOfTestCleanupPerformed + || !isConnected + || mIndexRunning != POSITION_TEST_IOP3_LE_PRIVACY + ) { + return@postDelayed + } Log.d( TAG, - "onConnectionStateChange connected mIndexRunningis 8" + "onConnectionStateChange: LE Privacy test complete" ) isTestRunning = false isTestFinished = true - + cancelPendingIopBluetoothWork() getItemTestCaseInfo(POSITION_TEST_IOP3_LE_PRIVACY).setStatusTest( Common.IOP3_TC_STATUS_PASS ) runOnUiThread { updateUIFooter(isTestRunning) } mListener?.updateUi() - } - }, CONNECTION_PERIOD) + }, CONNECTION_PERIOD) + } } else { //After OTA process started //get information @@ -3372,60 +4548,88 @@ class IOPTestActivity : AppCompatActivity() { BluetoothGatt.STATE_DISCONNECTED -> { Log.d(TAG, "Disconnected IOP Test device: " + System.currentTimeMillis()) + postExpertLog( + category = "LOG", + title = getString(R.string.iop_expert_log_disconnected, activeExpertTestId()), + tone = "warning" + ) isConnected = false + gattDiscoveryInProgress = false discoverTimeout = false - exit(mBluetoothGatt) - if (status != 0 && otaMode) { - if (status == 133) { - //reconnect after 30 seconds - handler?.postDelayed({ - Log.d(TAG, "onConnectionStateChange OTA status $status") - retryIOP3Failed(mIndexRunning, countReTest++) - }, 30000) + + if (securityWaitingForFirmwareDisconnect + && securityAwaitingReadAfterBondRemove + && mIndexRunning == POSITION_TEST_IOP3_SECURITY + && isTestRunning + && !isTestFinished + && !endOfTestCleanupPerformed + ) { + handler?.removeCallbacks(securityFirmwareDisconnectTimeoutRunnable) + securityWaitingForFirmwareDisconnect = false + isConnecting = true + mBluetoothDevice = mBluetoothDevice ?: gatt.device + mDeviceAddress = mBluetoothDevice?.address ?: mDeviceAddress + try { + mBluetoothGatt?.close() + } catch (_: Exception) { } + mBluetoothGatt = null + mBluetoothService?.clearConnectedGatt() + onSecurityLinkDownProceedWithBondRemove() } else { - if (!otaProcess && !isTestFinished) { - Log.d( - TAG, - "onConnectionStateChange ota_process $otaProcess,isConnecting $isConnecting" - ) - if (mIndexRunning > POSITION_TEST_IOP3_OTA_WITHOUT_ACK || mIndexRunning < POSITION_TEST_IOP3_OTA_ACK) { - if (!isConnecting) { - isConnecting = true - handler?.postDelayed({ - Log.d(TAG, "onConnectionStateChange re-connecting") - retryIOP3Failed(mIndexRunning, countReTest++) - }, 5000) - } - } else { - if (status == 133 || status == 8) { + exit(mBluetoothGatt) + if (status != 0 && otaMode) { + if (status == 133) { + //reconnect after 30 seconds + handler?.postDelayed({ + Log.d(TAG, "onConnectionStateChange OTA status $status") + retryIOP3Failed(mIndexRunning, countReTest++) + }, 30000) + } + } else { + if (!otaProcess && !isTestFinished) { + Log.d( + TAG, + "onConnectionStateChange ota_process $otaProcess,isConnecting $isConnecting" + ) + if (mIndexRunning > POSITION_TEST_IOP3_OTA_WITHOUT_ACK || mIndexRunning < POSITION_TEST_IOP3_OTA_ACK) { if (!isConnecting) { isConnecting = true handler?.postDelayed({ - Log.d(TAG, "onConnectionStateChange status: $status") + Log.d(TAG, "onConnectionStateChange re-connecting") retryIOP3Failed(mIndexRunning, countReTest++) - }, 30000) + }, 5000) } + } else { + if (status == 133 || status == 8) { + if (!isConnecting) { + isConnecting = true + handler?.postDelayed({ + Log.d(TAG, "onConnectionStateChange status: $status") + retryIOP3Failed(mIndexRunning, countReTest++) + }, 30000) + } + } + } + } else { + if (status == 133) { + handler?.postDelayed({ + Log.d(TAG, "onConnectionStateChange OTA status: $status") + if (!isTestFinished) { + retryIOP3Failed(mIndexRunning, countReTest++) + } + }, 30000) } } - } else { - if (status == 133) { - handler?.postDelayed({ - Log.d(TAG, "onConnectionStateChange OTA status: $status") - if(!isTestFinished) { - retryIOP3Failed(mIndexRunning, countReTest++) - } - }, 30000) + if (disconnectGatt) { + exit(gatt) + } + if (gatt.services.isEmpty()) { + exit(gatt) + } + if (!boolOTAbegin && !otaProcess) { + exit(gatt) } - } - if (disconnectGatt) { - exit(gatt) - } - if (gatt.services.isEmpty()) { - exit(gatt) - } - if (!boolOTAbegin && !otaProcess) { - exit(gatt) } } } @@ -3442,17 +4646,38 @@ class IOPTestActivity : AppCompatActivity() { override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { super.onServicesDiscovered(gatt, status) + gattDiscoveryInProgress = false gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) Log.d(TAG, "onServicesDiscovered(), status " + Integer.toHexString(status)) if (mBluetoothGatt != gatt) { mBluetoothGatt = gatt - handler?.postDelayed({ - refreshServices() - mBluetoothGatt?.readPhy() - }, 2000) + if (securityAwaitingReadAfterBondRemove + && mIndexRunning == POSITION_TEST_IOP3_SECURITY + && status == BluetoothGatt.GATT_SUCCESS + && isTestRunning + && !isTestFinished + ) { + discoverTimeout = false + runnable(gatt) + } else { + handler?.postDelayed({ + refreshServices() + mBluetoothGatt?.readPhy() + }, 2000) + } } else { discoverTimeout = false if (status != 0) { + if (securityAwaitingReadAfterBondRemove + && securityPendingControlByte == IOP_SECURITY_CONTROL_BONDING + && isTestRunning + && !isTestFinished + ) { + Log.w(TAG, "onServicesDiscovered failed after 0x03; reconnecting") + isConnecting = true + reconnect(BOND_REMOVE_DELAY_MS + BOND_RECONNECT_EXTRA_DELAY_MS) + return + } runOnUiThread { /*Toast.makeText( baseContext, @@ -3521,6 +4746,19 @@ class IOPTestActivity : AppCompatActivity() { TAG, "onCharacteristicRead: " + characteristic.uuid.toString() + " status " + status ) + if (status == BluetoothGatt.GATT_SUCCESS) { + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_read_result, + activeExpertTestId(), + characteristic.uuid.toString(), + formatCharacteristicValue(characteristic.value), + packetSizeOf(characteristic.value) + ), + tone = if (status == 0) "success" else "failure" + ) + } if (characteristic.uuid == GattCharacteristic.ModelNumberString.uuid) { getSiliconLabsTestInfo().iopBoard = @@ -3601,6 +4839,18 @@ class IOPTestActivity : AppCompatActivity() { super.onCharacteristicWrite(gatt, characteristic, status) Log.d(TAG, "onCharacteristicWrite: " + characteristic.uuid.toString()) Log.d(TAG, "onCharacteristicWrite: $status") + if (!otaProcess && status == BluetoothGatt.GATT_SUCCESS) { + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_write_result, + activeExpertTestId(), + characteristic.uuid.toString(), + packetSizeOf(characteristic.value) + ), + tone = "success" + ) + } if (!otaProcess) { updateDataTest(characteristic, 1, status) checkNextTestCase(characteristic, 2) @@ -3714,6 +4964,17 @@ class IOPTestActivity : AppCompatActivity() { TAG, "onCharacteristicChanged: " + characteristic.uuid.toString() + " len:" + characteristic.value.size ) + postExpertLog( + category = "LOG", + title = getString( + R.string.iop_expert_log_notification_received, + activeExpertTestId(), + characteristic.uuid.toString(), + formatCharacteristicValue(characteristic.value), + packetSizeOf(characteristic.value) + ), + tone = "info" + ) updateDataTest(characteristic, -1, -1) checkNextTestCase(characteristic, 0) // type 1: CharacteristicRead, 2:CharacteristicWrite, 0:Notify @@ -3759,15 +5020,19 @@ class IOPTestActivity : AppCompatActivity() { runChildrenTestCase(mIndexStartChildrenTest) } else if (mEndThroughputNotification) { mEndThroughputNotification = false - if (status == 0) { - finishItemTest( - POSITION_TEST_IOP3_THROUGHPUT, - getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_THROUGHPUT] - ) - } else if (getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_LE_PRIVACY] - .getStatusTest() == Common.IOP3_TC_STATUS_PROCESSING - ) { - iopPhase3RunTestCaseLEPrivacy(1) + if (throughputSpeedDialog != null) { + pendingThroughputDescriptorStatus = status + runOnUiThread { + if (!isFinishing && !isDestroyed) { + throughputDialogDoneButton?.isEnabled = true + startThroughputDialogAutoDismissTimer() + } else { + pendingThroughputDescriptorStatus = null + continueIopTestAfterThroughputDescriptorWrite(status) + } + } + } else { + runOnUiThread { continueIopTestAfterThroughputDescriptorWrite(status) } } /*} else if (getSiliconLabsTestInfo().listItemTest[POSITION_TEST_IOP3_CACHING] .getStatusTest() == Common.IOP3_TC_STATUS_PROCESSING) { @@ -3815,7 +5080,12 @@ class IOPTestActivity : AppCompatActivity() { BluetoothDevice.PHY_LE_1M_MASK, BluetoothDevice.PHY_OPTION_NO_PREFERRED ) - if (mIndexRunning == POSITION_TEST_DISCOVER_SERVICE || mIndexRunning == POSITION_TEST_IOP3_THROUGHPUT || mIndexRunning == POSITION_TEST_IOP3_LE_PRIVACY || (mIndexRunning == POSITION_TEST_IOP3_SECURITY && iopPhase3BondingStep > 2)) { + val skipServiceRefreshDuringThroughput = + mIndexRunning == POSITION_TEST_IOP3_THROUGHPUT && + (isThroughputMeterActive || throughputSpeedDialog != null) + if (skipServiceRefreshDuringThroughput) { + Log.d(TAG, "onMtuChanged during throughput measurement, skipping service refresh") + } else if (mIndexRunning == POSITION_TEST_DISCOVER_SERVICE || mIndexRunning == POSITION_TEST_IOP3_THROUGHPUT || mIndexRunning == POSITION_TEST_IOP3_LE_PRIVACY || (mIndexRunning == POSITION_TEST_IOP3_SECURITY && iopPhase3BondingStep > 2)) { mListCharacteristics.clear() characteristicsPhase3Security.clear() characteristicIOPPhase3Control = null @@ -3917,6 +5187,7 @@ class IOPTestActivity : AppCompatActivity() { private const val THROUGHPUT_NOTIFICATION_DISABLE_MAX_ATTEMPTS = 3 private const val THROUGHPUT_NOTIFICATION_DISABLE_RETRY_DELAY_MS = 200L + private const val THROUGHPUT_DIALOG_AUTO_DISMISS_MS = 10_000L private val ota_service = UUID.fromString("1d14d6ee-fd63-4fa1-bfa4-8f47b42119f0") private val ota_data = UUID.fromString("984227f3-34fc-4045-a5d0-2c581f81a153") @@ -3949,12 +5220,27 @@ class IOPTestActivity : AppCompatActivity() { private const val SCAN_PERIOD: Long = 20000 /** Delay after [BluetoothDevice.removeBond] before continuing the test so bond clears. */ - private const val BOND_REMOVE_DELAY_MS = 1500L + private const val BOND_REMOVE_DELAY_MS = 3000L + /** Wait for the GATT link to stabilize after pairing before starting service discovery. */ + private const val BOND_COMPLETE_DISCOVERY_DELAY_MS = 3500L + /** Back-off between bonded service-discovery retries when the link is not ready yet. */ + private const val BOND_DISCOVERY_RETRY_STEP_MS = 800L + /** Extra delay before reconnecting after bond removal. */ + private const val BOND_RECONNECT_EXTRA_DELAY_MS = 1000L + /** Max wait for firmware to disconnect after security control 0x02/0x03 before forcing it. */ + private const val SECURITY_FIRMWARE_DISCONNECT_TIMEOUT_MS = 8000L + /** IOP Phase-3 control characteristic security byte for authentication sub-test. */ + private const val IOP_SECURITY_CONTROL_AUTHENTICATION = 2 + /** IOP Phase-3 control characteristic security byte for bonding sub-test. */ + private const val IOP_SECURITY_CONTROL_BONDING = 3 private const val CONNECTION_PERIOD: Long = 10000 private const val BLUETOOTH_SETTINGS_REQUEST_CODE = 100 const val GBL_FILE_CHOICE_REQUEST_CODE = 201 + private const val TAB_STANDARD = 0 + private const val TAB_EXPERT = 1 + fun startActivity(context: Context) { val intent = Intent(context, IOPTestActivity::class.java) startActivity(context, intent, null) @@ -3962,6 +5248,7 @@ class IOPTestActivity : AppCompatActivity() { } private fun releaseIopBluetoothResources() { + cancelPendingIopBluetoothWork() mBluetoothService?.unregisterGattCallback() try { mBluetoothGatt?.disconnect() diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/adapters/IOPExpertLogAdapter.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/adapters/IOPExpertLogAdapter.kt new file mode 100644 index 00000000..56503d3b --- /dev/null +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/adapters/IOPExpertLogAdapter.kt @@ -0,0 +1,159 @@ +package com.siliconlabs.bledemo.features.iop_test.adapters + +import android.graphics.Typeface +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.siliconlabs.bledemo.R +import com.siliconlabs.bledemo.databinding.ItemIopExpertLogBinding +import com.siliconlabs.bledemo.features.iop_test.models.IOPExpertLogEntry + +class IOPExpertLogAdapter : RecyclerView.Adapter() { + + private val entries = ArrayList() + + fun setEntries(newEntries: List) { + entries.clear() + entries.addAll(newEntries) + notifyDataSetChanged() + } + + fun appendEntry(entry: IOPExpertLogEntry) { + entries.add(entry) + notifyItemInserted(entries.lastIndex) + } + + fun updateLastEntry(entry: IOPExpertLogEntry) { + if (entries.isEmpty()) return + entries[entries.lastIndex] = entry + notifyItemChanged(entries.lastIndex) + } + + fun clear() { + val size = entries.size + if (size == 0) return + entries.clear() + notifyItemRangeRemoved(0, size) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder { + val binding = ItemIopExpertLogBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return LogViewHolder(binding) + } + + override fun getItemCount(): Int = entries.size + + override fun onBindViewHolder(holder: LogViewHolder, position: Int) { + holder.bind(entries[position]) + } + + class LogViewHolder( + private val binding: ItemIopExpertLogBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(entry: IOPExpertLogEntry) { + val context = binding.root.context + val style = visualStyle(entry.tone) + val isMilestone = entry.isMilestone + val showCategory = shouldShowCategoryBadge(entry.category) + + binding.tvTimestamp.text = entry.timestamp + binding.tvTitle.text = entry.title + if (entry.detail.isNullOrBlank()) { + binding.tvDetail.visibility = View.GONE + } else { + binding.tvDetail.visibility = View.VISIBLE + binding.tvDetail.text = entry.detail + } + + if (showCategory) { + binding.tvCategory.visibility = View.VISIBLE + binding.tvCategory.text = if (entry.repeatCount > 1) { + " ${entry.category} x${entry.repeatCount} " + } else { + " ${entry.category} " + } + } else { + binding.tvCategory.visibility = View.GONE + } + + val accentWidth = if (isMilestone) { + context.resources.getDimensionPixelSize(R.dimen.iop_expert_accent_milestone) + } else { + context.resources.getDimensionPixelSize(R.dimen.iop_expert_accent_default) + } + binding.accentBar.layoutParams.width = accentWidth + + binding.accentBar.setBackgroundColor(style.accentColor) + binding.cardContainer.setCardBackgroundColor(style.backgroundColor) + binding.cardContainer.strokeColor = style.borderColor + binding.cardContainer.strokeWidth = if (isMilestone) 0 else context.resources.getDimensionPixelSize(R.dimen.matter_1dp) + + binding.tvTimestamp.setTextColor(style.accentColor) + binding.tvCategory.setTextColor(style.accentColor) + binding.tvCategory.setBackgroundColor(style.backgroundColor) + binding.tvTitle.setTextColor(style.titleColor) + binding.tvDetail.setTextColor( + if (isMilestone) style.titleColor else ContextCompat.getColor(context, R.color.silabs_redtheme_body_text_color) + ) + + binding.tvTitle.textSize = if (isMilestone) 14f else 13f + binding.tvTitle.setTypeface(binding.tvTitle.typeface, if (isMilestone) Typeface.BOLD else Typeface.NORMAL) + } + + private fun shouldShowCategoryBadge(category: String): Boolean { + return category in listOf("PASS", "FAIL", "WAIT", "RUN", "SCENARIO", "TEST") + } + + private data class VisualStyle( + val accentColor: Int, + val backgroundColor: Int, + val borderColor: Int, + val titleColor: Int + ) + + private fun visualStyle(tone: String): VisualStyle { + val context = binding.root.context + return when (tone) { + "success" -> VisualStyle( + ContextCompat.getColor(context, R.color.silabs_green), + ContextCompat.getColor(context, R.color.iop_expert_tone_success_bg), + ContextCompat.getColor(context, R.color.iop_expert_tone_success_border), + ContextCompat.getColor(context, R.color.silabs_redtheme_header_text_color) + ) + "failure" -> VisualStyle( + ContextCompat.getColor(context, R.color.silabs_red), + ContextCompat.getColor(context, R.color.iop_expert_tone_failure_bg), + ContextCompat.getColor(context, R.color.iop_expert_tone_failure_border), + ContextCompat.getColor(context, R.color.silabs_red_dark) + ) + "warning" -> VisualStyle( + ContextCompat.getColor(context, R.color.silabs_yellow), + ContextCompat.getColor(context, R.color.iop_expert_tone_warning_bg), + ContextCompat.getColor(context, R.color.iop_expert_tone_warning_border), + ContextCompat.getColor(context, R.color.silabs_redtheme_header_text_color) + ) + "session" -> VisualStyle( + ContextCompat.getColor(context, R.color.silabs_red), + ContextCompat.getColor(context, R.color.iop_expert_tone_session_bg), + ContextCompat.getColor(context, R.color.iop_expert_tone_session_border), + ContextCompat.getColor(context, R.color.silabs_redtheme_header_text_color) + ) + "test" -> VisualStyle( + ContextCompat.getColor(context, android.R.color.darker_gray), + ContextCompat.getColor(context, R.color.iop_expert_tone_test_bg), + ContextCompat.getColor(context, R.color.iop_expert_tone_test_border), + ContextCompat.getColor(context, R.color.silabs_redtheme_header_text_color) + ) + else -> VisualStyle( + ContextCompat.getColor(context, R.color.silabs_blue), + ContextCompat.getColor(context, R.color.iop_expert_tone_info_bg), + ContextCompat.getColor(context, R.color.iop_expert_tone_info_border), + ContextCompat.getColor(context, R.color.silabs_redtheme_header_text_color) + ) + } + } + } +} diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/adapters/IOPGattInfoAdapter.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/adapters/IOPGattInfoAdapter.kt new file mode 100644 index 00000000..2b1c6ecc --- /dev/null +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/adapters/IOPGattInfoAdapter.kt @@ -0,0 +1,105 @@ +package com.siliconlabs.bledemo.features.iop_test.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.google.android.flexbox.FlexboxLayout +import com.siliconlabs.bledemo.databinding.ItemIopGattCharacteristicInfoBinding +import com.siliconlabs.bledemo.databinding.ItemIopGattServiceHeaderBinding +import com.siliconlabs.bledemo.features.iop_test.models.IOPGattListItem +import com.siliconlabs.bledemo.features.iop_test.models.IOPGattProperty + +class IOPGattInfoAdapter : RecyclerView.Adapter() { + + private val items = ArrayList() + + fun submitServices(services: List) { + items.clear() + items.addAll(services) + notifyDataSetChanged() + } + + override fun getItemViewType(position: Int): Int = when (items[position]) { + is IOPGattListItem.ServiceHeader -> VIEW_TYPE_HEADER + is IOPGattListItem.CharacteristicRow -> VIEW_TYPE_CHARACTERISTIC + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_HEADER -> ServiceHeaderViewHolder( + ItemIopGattServiceHeaderBinding.inflate(inflater, parent, false) + ) + else -> CharacteristicViewHolder( + ItemIopGattCharacteristicInfoBinding.inflate(inflater, parent, false) + ) + } + } + + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = items[position]) { + is IOPGattListItem.ServiceHeader -> (holder as ServiceHeaderViewHolder).bind(item) + is IOPGattListItem.CharacteristicRow -> (holder as CharacteristicViewHolder).bind(item) + } + } + + private class ServiceHeaderViewHolder( + private val binding: ItemIopGattServiceHeaderBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: IOPGattListItem.ServiceHeader) { + binding.tvServiceName.text = item.name + binding.tvServiceUuid.text = item.uuid + } + } + + private class CharacteristicViewHolder( + private val binding: ItemIopGattCharacteristicInfoBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: IOPGattListItem.CharacteristicRow) { + binding.tvCharacteristicName.text = item.name + binding.tvCharacteristicUuid.text = item.uuid + binding.flexProperties.removeAllViews() + if (item.properties.isEmpty()) { + binding.flexProperties.addView( + TextView(binding.root.context).apply { + text = "—" + setTextColor(ContextCompat.getColor(context, com.siliconlabs.bledemo.R.color.silabs_redtheme_body_text_color)) + textSize = 11f + } + ) + } else { + item.properties.forEach { property -> + binding.flexProperties.addView(createBadge(property)) + } + } + } + + private fun createBadge(property: IOPGattProperty): TextView { + val context = binding.root.context + return TextView(context).apply { + text = property.label + textSize = 10f + setTextColor(ContextCompat.getColor(context, property.colorRes)) + setBackgroundResource(property.backgroundDrawableRes) + val horizontal = (8 * resources.displayMetrics.density).toInt() + val vertical = (3 * resources.displayMetrics.density).toInt() + setPadding(horizontal, vertical, horizontal, vertical) + val params = FlexboxLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.setMargins(0, 0, (6 * resources.displayMetrics.density).toInt(), 0) + layoutParams = params + } + } + } + + companion object { + private const val VIEW_TYPE_HEADER = 0 + private const val VIEW_TYPE_CHARACTERISTIC = 1 + } +} diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/dialogs/IOPGattInfoDialog.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/dialogs/IOPGattInfoDialog.kt new file mode 100644 index 00000000..479e6975 --- /dev/null +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/dialogs/IOPGattInfoDialog.kt @@ -0,0 +1,313 @@ +package com.siliconlabs.bledemo.features.iop_test.dialogs + +import android.annotation.SuppressLint +import android.app.Dialog +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.LinearLayoutManager +import com.siliconlabs.bledemo.R +import com.siliconlabs.bledemo.databinding.DialogIopGattInfoBinding +import com.siliconlabs.bledemo.features.iop_test.adapters.IOPGattInfoAdapter +import com.siliconlabs.bledemo.features.iop_test.models.IOPGattDiscoveredCharacteristic +import com.siliconlabs.bledemo.features.iop_test.models.IOPGattDiscoveredService +import com.siliconlabs.bledemo.features.iop_test.models.IOPGattListItem +import com.siliconlabs.bledemo.features.iop_test.models.IOPGattProperty +import com.siliconlabs.bledemo.features.iop_test.models.IOPGattReferenceCatalog +import java.util.Locale + +class IOPGattInfoDialog : DialogFragment() { + + private var _binding: DialogIopGattInfoBinding? = null + private val binding get() = _binding!! + + private val adapter = IOPGattInfoAdapter() + private val handler = Handler(Looper.getMainLooper()) + private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() + + private var targetAddress: String? = null + private var bluetoothGatt: BluetoothGatt? = null + private var isScanning = false + private var didStartConnecting = false + private var didFinishLoading = false + private val discoveredServices = ArrayList() + + private val scanTimeoutRunnable = Runnable { handleScanTimeout() } + + private val scanCallback = object : ScanCallback() { + @SuppressLint("MissingPermission") + override fun onScanResult(callbackType: Int, result: ScanResult) { + val address = targetAddress ?: return + if (result.device.address.equals(address, ignoreCase = true)) { + if (isAdded) { + requireActivity().runOnUiThread { onTargetDeviceFound(result.device) } + } + } + } + + override fun onScanFailed(errorCode: Int) { + if (isAdded) { + requireActivity().runOnUiThread { + showStatus(getString(R.string.iop_gatt_status_discover_failed), loading = false) + } + } + } + } + + private val gattCallback = object : BluetoothGattCallback() { + @SuppressLint("MissingPermission") + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (!isAdded) return + requireActivity().runOnUiThread { + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + showStatus(getString(R.string.iop_gatt_status_discovering), loading = true) + if (!gatt.discoverServices()) { + showStatus(getString(R.string.iop_gatt_status_discover_failed), loading = false) + } + } + + BluetoothProfile.STATE_DISCONNECTED -> { + if (!didFinishLoading) { + showStatus(getString(R.string.iop_gatt_status_disconnected), loading = false) + } + } + } + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (!isAdded) return + requireActivity().runOnUiThread { + if (status != BluetoothGatt.GATT_SUCCESS) { + showStatus(getString(R.string.iop_gatt_status_discover_failed), loading = false) + return@runOnUiThread + } + val services = gatt.services ?: emptyList() + discoveredServices.clear() + discoveredServices.addAll( + services.map { service -> + val characteristics = service.characteristics.map { characteristic -> + IOPGattDiscoveredCharacteristic( + name = IOPGattReferenceCatalog.characteristicName( + characteristic.uuid, + requireContext() + ), + uuid = characteristic.uuid.toString().uppercase(Locale.US), + properties = characteristic.properties + ) + } + IOPGattDiscoveredService( + name = IOPGattReferenceCatalog.serviceName( + service.uuid, + requireContext() + ), + uuid = service.uuid.toString().uppercase(Locale.US), + characteristics = characteristics + ) + } + ) + finishLoading() + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + isCancelable = true + setCanceledOnTouchOutside(true) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = DialogIopGattInfoBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val deviceName = arguments?.getString(ARG_DEVICE_NAME)?.trim().orEmpty() + targetAddress = arguments?.getString(ARG_DEVICE_ADDRESS)?.trim() + + binding.tvGattTitle.text = deviceName.ifEmpty { getString(R.string.iop_gatt_table_title) } + binding.btnGattClose.setOnClickListener { dismiss() } + + binding.rvGattInfo.layoutManager = LinearLayoutManager(requireContext()) + binding.rvGattInfo.adapter = adapter + + if (targetAddress.isNullOrBlank()) { + showStatus(getString(R.string.iop_gatt_status_no_device), loading = false) + return + } + + showStatus(getString(R.string.iop_gatt_status_connecting), loading = true) + beginConnectionFlow() + } + + override fun onStart() { + super.onStart() + val metrics = resources.displayMetrics + dialog?.window?.setLayout( + (metrics.widthPixels * 0.92f).toInt(), + (metrics.heightPixels * 0.82f).toInt() + ) + } + + override fun onDestroyView() { + teardownConnection() + handler.removeCallbacksAndMessages(null) + _binding = null + super.onDestroyView() + } + + @SuppressLint("MissingPermission") + private fun beginConnectionFlow() { + val adapter = bluetoothAdapter + if (adapter == null || !adapter.isEnabled) { + showStatus(getString(R.string.iop_gatt_status_bluetooth_off), loading = false) + return + } + startScanningIfNeeded() + } + + @SuppressLint("MissingPermission") + private fun startScanningIfNeeded() { + if (isScanning) return + isScanning = true + showStatus(getString(R.string.iop_gatt_status_searching), loading = true) + try { + bluetoothAdapter?.bluetoothLeScanner?.startScan(scanCallback) + handler.postDelayed(scanTimeoutRunnable, SCAN_TIMEOUT_MS) + } catch (e: SecurityException) { + Log.e(TAG, "BLE scan permission denied", e) + isScanning = false + showStatus(getString(R.string.iop_gatt_status_discover_failed), loading = false) + } + } + + @SuppressLint("MissingPermission") + private fun onTargetDeviceFound(device: BluetoothDevice) { + if (didStartConnecting) return + didStartConnecting = true + handler.removeCallbacks(scanTimeoutRunnable) + stopScanning() + showStatus(getString(R.string.iop_gatt_status_connecting), loading = true) + try { + bluetoothGatt = device.connectGatt(requireContext(), false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } catch (e: SecurityException) { + Log.e(TAG, "BLE connect permission denied", e) + showStatus(getString(R.string.iop_gatt_status_discover_failed), loading = false) + } + } + + private fun handleScanTimeout() { + if (didStartConnecting || didFinishLoading) return + stopScanning() + showStatus(getString(R.string.iop_gatt_status_not_found), loading = false) + } + + @SuppressLint("MissingPermission") + private fun stopScanning() { + if (!isScanning) return + isScanning = false + try { + bluetoothAdapter?.bluetoothLeScanner?.stopScan(scanCallback) + } catch (e: SecurityException) { + Log.e(TAG, "BLE stop scan permission denied", e) + } + } + + @SuppressLint("MissingPermission") + private fun teardownConnection() { + handler.removeCallbacks(scanTimeoutRunnable) + stopScanning() + try { + bluetoothGatt?.disconnect() + } catch (_: Exception) { + } + try { + bluetoothGatt?.close() + } catch (_: Exception) { + } + bluetoothGatt = null + } + + private fun showStatus(text: String, loading: Boolean) { + if (_binding == null) return + binding.statusContainer.visibility = View.VISIBLE + binding.tvStatus.text = text + binding.progressIndicator.visibility = if (loading) View.VISIBLE else View.GONE + } + + private fun hideStatus() { + if (_binding == null) return + binding.statusContainer.visibility = View.GONE + binding.progressIndicator.visibility = View.GONE + } + + private fun finishLoading() { + if (didFinishLoading) return + didFinishLoading = true + val listItems = buildListItems() + if (listItems.isEmpty()) { + showStatus(getString(R.string.iop_gatt_status_no_services), loading = false) + } else { + hideStatus() + adapter.submitServices(listItems) + binding.rvGattInfo.scrollToPosition(0) + } + } + + private fun buildListItems(): List { + val result = ArrayList() + discoveredServices.forEach { service -> + result.add(IOPGattListItem.ServiceHeader(service.name, service.uuid)) + service.characteristics.forEach { characteristic -> + result.add( + IOPGattListItem.CharacteristicRow( + name = characteristic.name, + uuid = characteristic.uuid, + properties = IOPGattProperty.fromCharacteristicProperties(characteristic.properties) + ) + ) + } + } + return result + } + + companion object { + private const val TAG = "IOPGattInfoDialog" + private const val ARG_DEVICE_NAME = "device_name" + private const val ARG_DEVICE_ADDRESS = "device_address" + private const val SCAN_TIMEOUT_MS = 15_000L + + fun newInstance(deviceName: String, deviceAddress: String): IOPGattInfoDialog { + return IOPGattInfoDialog().apply { + arguments = Bundle().apply { + putString(ARG_DEVICE_NAME, deviceName) + putString(ARG_DEVICE_ADDRESS, deviceAddress) + } + } + } + } +} diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/fragments/IOPExpertFragment.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/fragments/IOPExpertFragment.kt new file mode 100644 index 00000000..8de4fec4 --- /dev/null +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/fragments/IOPExpertFragment.kt @@ -0,0 +1,66 @@ +package com.siliconlabs.bledemo.features.iop_test.fragments + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import by.kirich1409.viewbindingdelegate.viewBinding +import com.siliconlabs.bledemo.R +import com.siliconlabs.bledemo.databinding.FragmentIopExpertBinding +import com.siliconlabs.bledemo.features.iop_test.activities.IOPExpertListener +import com.siliconlabs.bledemo.features.iop_test.activities.IOPTestActivity +import com.siliconlabs.bledemo.features.iop_test.adapters.IOPExpertLogAdapter +import com.siliconlabs.bledemo.features.iop_test.models.IOPExpertLogEntry + +class IOPExpertFragment : Fragment(R.layout.fragment_iop_expert), IOPExpertListener { + + private val binding by viewBinding(FragmentIopExpertBinding::bind) + private val logAdapter = IOPExpertLogAdapter() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.rvExpertLog.apply { + layoutManager = LinearLayoutManager(context) + adapter = logAdapter + } + updateEmptyState() + (activity as? IOPTestActivity)?.setExpertListener(this) + } + + override fun appendLogEntry(entry: IOPExpertLogEntry) { + if (!isAdded) return + logAdapter.appendEntry(entry) + updateEmptyState() + binding.rvExpertLog.post { + if (logAdapter.itemCount > 0) { + binding.rvExpertLog.smoothScrollToPosition(logAdapter.itemCount - 1) + } + } + } + + override fun restoreLog(entries: List) { + if (!isAdded) return + logAdapter.setEntries(entries) + updateEmptyState() + if (entries.isNotEmpty()) { + binding.rvExpertLog.post { + binding.rvExpertLog.scrollToPosition(entries.lastIndex) + } + } + } + + override fun clearLog() { + if (!isAdded) return + logAdapter.clear() + updateEmptyState() + } + + private fun updateEmptyState() { + binding.tvExpertEmpty.visibility = + if (logAdapter.itemCount == 0) View.VISIBLE else View.GONE + } + + companion object { + fun newExpertInstance(): IOPExpertFragment = IOPExpertFragment() + } +} diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPExpertLogEntry.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPExpertLogEntry.kt new file mode 100644 index 00000000..8f379746 --- /dev/null +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPExpertLogEntry.kt @@ -0,0 +1,22 @@ +package com.siliconlabs.bledemo.features.iop_test.models + +data class IOPExpertLogEntry( + var timestamp: String, + val category: String, + val title: String, + val detail: String?, + val tone: String, + var repeatCount: Int = 1 +) { + val isMilestone: Boolean + get() = tone == "session" || tone == "test" || category == "SCENARIO" + + fun canCollapseWith(other: IOPExpertLogEntry): Boolean { + return !isMilestone && + !other.isMilestone && + category == other.category && + title == other.title && + detail == other.detail && + tone == other.tone + } +} diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPGattDiscoveredModels.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPGattDiscoveredModels.kt new file mode 100644 index 00000000..b09a42c3 --- /dev/null +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPGattDiscoveredModels.kt @@ -0,0 +1,23 @@ +package com.siliconlabs.bledemo.features.iop_test.models + +sealed class IOPGattListItem { + data class ServiceHeader(val name: String, val uuid: String) : IOPGattListItem() + + data class CharacteristicRow( + val name: String, + val uuid: String, + val properties: List + ) : IOPGattListItem() +} + +data class IOPGattDiscoveredService( + val name: String, + val uuid: String, + var characteristics: List = emptyList() +) + +data class IOPGattDiscoveredCharacteristic( + val name: String, + val uuid: String, + val properties: Int +) diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPGattProperty.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPGattProperty.kt new file mode 100644 index 00000000..a4c0a005 --- /dev/null +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPGattProperty.kt @@ -0,0 +1,35 @@ +package com.siliconlabs.bledemo.features.iop_test.models + +import android.bluetooth.BluetoothGattCharacteristic +import androidx.annotation.ColorRes +import com.siliconlabs.bledemo.R + +enum class IOPGattProperty( + val label: String, + @ColorRes val colorRes: Int, + val backgroundDrawableRes: Int +) { + READ("READ", R.color.silabs_blue, R.drawable.iop_gatt_property_read_bg), + WRITE("WRITE", R.color.silabs_yellow, R.drawable.iop_gatt_property_write_bg), + NOTIFY("NOTIFY", R.color.silabs_green, R.drawable.iop_gatt_property_notify_bg); + + companion object { + fun fromCharacteristicProperties(properties: Int): List { + val result = mutableListOf() + if (properties and BluetoothGattCharacteristic.PROPERTY_READ != 0) { + result.add(READ) + } + if (properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 || + properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0 + ) { + result.add(WRITE) + } + if (properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0 || + properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0 + ) { + result.add(NOTIFY) + } + return result + } + } +} diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPGattReferenceCatalog.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPGattReferenceCatalog.kt new file mode 100644 index 00000000..25e8a638 --- /dev/null +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/IOPGattReferenceCatalog.kt @@ -0,0 +1,76 @@ +package com.siliconlabs.bledemo.features.iop_test.models + +import android.content.Context +import com.siliconlabs.bledemo.bluetooth.parsing.Common +import java.util.Locale +import java.util.UUID + +object IOPGattReferenceCatalog { + private val serviceNames = mapOf( + "0000180a-0000-1000-8000-00805f9b34fb" to "Device Information Service", + CommonUUID.Service.TEST_PARAMETERS.toString().lowercase(Locale.US) to "IOP Test Service", + CommonUUID.Service.UUID_PROPERTIES_SERVICE.toString().lowercase(Locale.US) to "IOP Test Properties Service", + CommonUUID.Service.UUID_CHARACTERISTICS_SERVICE.toString().lowercase(Locale.US) to + "IOP Test Characteristic Types Service", + CommonUUID.Service.UUID_PHASE3_SERVICE.toString().lowercase(Locale.US) to "IOP Test Phase 3 Service", + CommonUUID.Service.UUID_BLE_OTA.toString().lowercase(Locale.US) to "BLE OTA Service", + CommonUUID.Service.UUID_GENERIC_ATTRIBUTE.toString().lowercase(Locale.US) to "Generic Attribute Service", + CommonUUID.Service.UUID_GENERIC_ACCESS.toString().lowercase(Locale.US) to "Generic Access Service" + ) + + private val characteristicNames = mapOf( + "00002a24-0000-1000-8000-00805f9b34fb" to "Model Number String", + CommonUUID.Characteristic.FIRMWARE_VERSION.toString().lowercase(Locale.US) to "IOP Test Version", + CommonUUID.Characteristic.CONNECTION_PARAMETERS.toString().lowercase(Locale.US) to "IOP Test Connection", + "a432d31f-9022-4045-96ff-32258ffe7192" to "IOP Test Control RFU", + CommonUUID.Characteristic.READ_ONLY_LENGTH_1.toString().lowercase(Locale.US) to "IOP Test Read Only Length 1", + CommonUUID.Characteristic.READ_ONLY_LENGTH_255.toString().lowercase(Locale.US) to + "IOP Test Read Only Length 255", + CommonUUID.Characteristic.WRITE_ONLY_LENGTH_1.toString().lowercase(Locale.US) to "IOP Test Write Only Length 1", + CommonUUID.Characteristic.WRITE_ONLY_LENGTH_255.toString().lowercase(Locale.US) to + "IOP Test Write Only Length 255", + CommonUUID.Characteristic.WRITE_WITHOUT_RESPONSE_LENGTH_1.toString().lowercase(Locale.US) to + "IOP Test Write Without Response Length 1", + CommonUUID.Characteristic.WRITE_WITHOUT_RESPONSE_LENGTH_255.toString().lowercase(Locale.US) to + "IOP Test Write Without Response Length 255", + CommonUUID.Characteristic.NOTIFICATION_LENGTH_1.toString().lowercase(Locale.US) to "IOP Test Notify Length 1", + CommonUUID.Characteristic.NOTIFICATION_LENGTH_MTU_3.toString().lowercase(Locale.US) to + "IOP Test Notify Length MTU - 3", + CommonUUID.Characteristic.INDICATE_LENGTH_1.toString().lowercase(Locale.US) to "IOP Test Indicate Length 1", + CommonUUID.Characteristic.INDICATE_LENGTH_MTU_3.toString().lowercase(Locale.US) to + "IOP Test Indicate Length MTU - 3", + CommonUUID.Characteristic.IOP_TEST_LENGTH_1.toString().lowercase(Locale.US) to "IOP Test Length 1", + CommonUUID.Characteristic.IOP_TEST_LENGTH_255.toString().lowercase(Locale.US) to "IOP Test Length 255", + CommonUUID.Characteristic.IOP_TEST_LENGTH_VARIABLE_4.toString().lowercase(Locale.US) to + "IOP Test Length Variable 4", + CommonUUID.Characteristic.IOP_TEST_CONST_LENGTH_1.toString().lowercase(Locale.US) to "IOP Test Const Length 1", + CommonUUID.Characteristic.IOP_TEST_CONST_LENGTH_255.toString().lowercase(Locale.US) to + "IOP Test Const Length 255", + CommonUUID.Characteristic.IOP_TEST_USER_LEN_1.toString().lowercase(Locale.US) to "IOP Test User Len 1", + CommonUUID.Characteristic.IOP_TEST_USER_LEN_255.toString().lowercase(Locale.US) to "IOP Test User Len 255", + CommonUUID.Characteristic.IOP_TEST_USER_LEN_VARIABLE_4.toString().lowercase(Locale.US) to + "IOP Test User Len Variable 4", + CommonUUID.Characteristic.IOP_TEST_PHASE3_CONTROL.toString().lowercase(Locale.US) to "IOP Test Phase 3 Control", + CommonUUID.Characteristic.IOP_TEST_SECURITY_PAIRING.toString().lowercase(Locale.US) to + "IOP Test Security Pairing", + CommonUUID.Characteristic.IOP_TEST_SECURITY_AUTHENTICATION.toString().lowercase(Locale.US) to + "IOP Test Security Authentication", + CommonUUID.Characteristic.IOP_TEST_SECURITY_BONDING.toString().lowercase(Locale.US) to + "IOP Test Security Bonding", + CommonUUID.Characteristic.IOP_TEST_THROUGHPUT.toString().lowercase(Locale.US) to + "IOP Test Throughput GATT Notification", + CommonUUID.Characteristic.IOP_TEST_GATT_CATCHING.toString().lowercase(Locale.US) to "IOP Test GATT Caching 7.5", + "6a978442-f37b-a07c-1a5f-0e6f15a5fc83" to "IOP Test Security Bonding", + "b5178061-69ce-46a9-3740-7b3c580953b0" to "IOP Test GATT Caching 7.5" + ) + + fun serviceName(uuid: UUID, context: Context): String { + val key = uuid.toString().lowercase(Locale.US) + return serviceNames[key] ?: Common.getServiceName(uuid, context) + } + + fun characteristicName(uuid: UUID, context: Context): String { + val key = uuid.toString().lowercase(Locale.US) + return characteristicNames[key] ?: Common.getCharacteristicName(uuid, context) + } +} diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/SiliconLabsTestInfo.kt b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/SiliconLabsTestInfo.kt index cb9cca9b..fad4b5c8 100644 --- a/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/SiliconLabsTestInfo.kt +++ b/mobile/src/main/java/com/siliconlabs/bledemo/features/iop_test/models/SiliconLabsTestInfo.kt @@ -113,11 +113,7 @@ class SiliconLabsTestInfo(var fwName: String, var deviceMacAddress: String, val } // Append test case 7.5 after test case 7.4 if (itemTest.idTest == 8 && !printedTestCase7_5) { - if (iopActivity.isCCCDPass) { - append("\tTest case 7.5,Pass.\n") - } else { - append("\tTest case 7.5,Fail.\n") - } + append("\tTest case 7.5 is not applicable for Android.\n") printedTestCase7_5 = true // Set the flag to true after printing test case 7.5 } diff --git a/mobile/src/main/java/com/siliconlabs/bledemo/home_screen/viewmodels/SelectDeviceViewModel.kt b/mobile/src/main/java/com/siliconlabs/bledemo/home_screen/viewmodels/SelectDeviceViewModel.kt index e06d7bcc..3a6bc69f 100644 --- a/mobile/src/main/java/com/siliconlabs/bledemo/home_screen/viewmodels/SelectDeviceViewModel.kt +++ b/mobile/src/main/java/com/siliconlabs/bledemo/home_screen/viewmodels/SelectDeviceViewModel.kt @@ -128,10 +128,9 @@ class SelectDeviceViewModel : ScannerViewModel() { if (connectType != null && connectType == BluetoothService.GattConnectType.IOP_TEST) { if (deviceName != null) { if (context != null) { - if (!deviceName.startsWith( - "IOP", - ignoreCase = true - ) + val matchesIopName = deviceName.startsWith("IOP_Test", ignoreCase = true) + || deviceName.startsWith("IOP Test", ignoreCase = true) + if (!matchesIopName && !matchesManufacturerData(result, manufacturerDataFilter) ) { shouldAddDevice = false diff --git a/mobile/src/main/res/color/iop_mode_tab_text.xml b/mobile/src/main/res/color/iop_mode_tab_text.xml new file mode 100644 index 00000000..4b62eb16 --- /dev/null +++ b/mobile/src/main/res/color/iop_mode_tab_text.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/drawable/ic_info_white.xml b/mobile/src/main/res/drawable/ic_info_white.xml new file mode 100644 index 00000000..fdc0dbec --- /dev/null +++ b/mobile/src/main/res/drawable/ic_info_white.xml @@ -0,0 +1,10 @@ + + + + diff --git a/mobile/src/main/res/drawable/iop_gatt_property_notify_bg.xml b/mobile/src/main/res/drawable/iop_gatt_property_notify_bg.xml new file mode 100644 index 00000000..4faf1283 --- /dev/null +++ b/mobile/src/main/res/drawable/iop_gatt_property_notify_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/mobile/src/main/res/drawable/iop_gatt_property_read_bg.xml b/mobile/src/main/res/drawable/iop_gatt_property_read_bg.xml new file mode 100644 index 00000000..5ccb8f9f --- /dev/null +++ b/mobile/src/main/res/drawable/iop_gatt_property_read_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/mobile/src/main/res/drawable/iop_gatt_property_write_bg.xml b/mobile/src/main/res/drawable/iop_gatt_property_write_bg.xml new file mode 100644 index 00000000..7a0fa5ad --- /dev/null +++ b/mobile/src/main/res/drawable/iop_gatt_property_write_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/mobile/src/main/res/drawable/iop_mode_tab_background.xml b/mobile/src/main/res/drawable/iop_mode_tab_background.xml new file mode 100644 index 00000000..ba6e9e15 --- /dev/null +++ b/mobile/src/main/res/drawable/iop_mode_tab_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout-sw600dp/dialog_characteristic_write.xml b/mobile/src/main/res/layout-sw600dp/dialog_characteristic_write.xml index 8e4b8efc..93c9c8ad 100644 --- a/mobile/src/main/res/layout-sw600dp/dialog_characteristic_write.xml +++ b/mobile/src/main/res/layout-sw600dp/dialog_characteristic_write.xml @@ -1,9 +1,10 @@ + android:layout_gravity="center_horizontal"> + android:text="@string/dialog_char_write_title" /> + android:orientation="vertical"> + android:text="@string/Write_with_response" + android:textColor="@color/silabs_redtheme_scanner_body_text_color" /> + android:text="@string/Write_without_response" + android:textColor="@color/silabs_redtheme_scanner_body_text_color" /> + android:text="@string/button_cancel" + app:cornerRadius="@dimen/silabs_btn_radius"/> + android:text="@string/button_clear" + app:cornerRadius="@dimen/silabs_btn_radius"/> diff --git a/mobile/src/main/res/layout/activity_iop_test.xml b/mobile/src/main/res/layout/activity_iop_test.xml index 117bfc4c..d35dd7e7 100644 --- a/mobile/src/main/res/layout/activity_iop_test.xml +++ b/mobile/src/main/res/layout/activity_iop_test.xml @@ -31,10 +31,42 @@ android:background="@color/silabs_redtheme_tool_bar_color" android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" /> - + + + + + + diff --git a/mobile/src/main/res/layout/dialog_iop_gatt_info.xml b/mobile/src/main/res/layout/dialog_iop_gatt_info.xml new file mode 100644 index 00000000..769125f3 --- /dev/null +++ b/mobile/src/main/res/layout/dialog_iop_gatt_info.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/dialog_iop_throughput_speed.xml b/mobile/src/main/res/layout/dialog_iop_throughput_speed.xml new file mode 100644 index 00000000..f1ae6286 --- /dev/null +++ b/mobile/src/main/res/layout/dialog_iop_throughput_speed.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/dialog_share_iop_log.xml b/mobile/src/main/res/layout/dialog_share_iop_log.xml index 3bdef6d2..40cf0ffe 100644 --- a/mobile/src/main/res/layout/dialog_share_iop_log.xml +++ b/mobile/src/main/res/layout/dialog_share_iop_log.xml @@ -43,6 +43,22 @@ android:layout_height="@dimen/matter_1dp" android:background="@color/tb_grey"/> + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/fragment_iop_test.xml b/mobile/src/main/res/layout/fragment_iop_test.xml index b2ca5e71..60644207 100644 --- a/mobile/src/main/res/layout/fragment_iop_test.xml +++ b/mobile/src/main/res/layout/fragment_iop_test.xml @@ -1,7 +1,11 @@ - - \ No newline at end of file + + + diff --git a/mobile/src/main/res/layout/fragment_settings.xml b/mobile/src/main/res/layout/fragment_settings.xml index c31747e2..b868b5cc 100644 --- a/mobile/src/main/res/layout/fragment_settings.xml +++ b/mobile/src/main/res/layout/fragment_settings.xml @@ -4,8 +4,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/modal_bg" - android:orientation="vertical" - > + android:orientation="vertical"> + android:layout_height="wrap_content"> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/item_iop_gatt_characteristic_info.xml b/mobile/src/main/res/layout/item_iop_gatt_characteristic_info.xml new file mode 100644 index 00000000..43cc1ca6 --- /dev/null +++ b/mobile/src/main/res/layout/item_iop_gatt_characteristic_info.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/item_iop_gatt_service_header.xml b/mobile/src/main/res/layout/item_iop_gatt_service_header.xml new file mode 100644 index 00000000..da47f20b --- /dev/null +++ b/mobile/src/main/res/layout/item_iop_gatt_service_header.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/mobile/src/main/res/menu/menu_iop_test.xml b/mobile/src/main/res/menu/menu_iop_test.xml index 7b6a43f7..d77097f0 100644 --- a/mobile/src/main/res/menu/menu_iop_test.xml +++ b/mobile/src/main/res/menu/menu_iop_test.xml @@ -2,6 +2,12 @@ + + #F9F9F9 #F8F8FA #F9C80E + + #1F14A359 + #4014A359 + #1FD91E2A + #40D91E2A + #1FF9C80E + #40F9C80E + #1AD91E2A + #33D91E2A + #14000000 + #2E000000 + #1A0086D9 + #380086D9 \ No newline at end of file diff --git a/mobile/src/main/res/values/dimens.xml b/mobile/src/main/res/values/dimens.xml index 68835bd2..53264ada 100644 --- a/mobile/src/main/res/values/dimens.xml +++ b/mobile/src/main/res/values/dimens.xml @@ -229,6 +229,8 @@ @dimen/space_S 16dp + 264dp + 320dp 12dp 16dp 40dp @@ -300,7 +302,7 @@ 8dp 18sp 90dp - 160dp + 162dp 12dp @@ -319,6 +321,8 @@ 0dp 1dp + 4dp + 8dp 2dp 4dp 5dp diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index 2a529093..12e2524d 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -572,6 +572,11 @@ Bluetooth is not supported %d %% %d bytes + MTU size: %1$d bytes + Buffer size: %1$d bytes + Peak throughput: %1$s %2$s + Average throughput: %1$s %2$s + Target throughput threshold: %1$s %2$s Resetting… 1 OF 1 This feature will execute a set of Bluetooth @@ -579,6 +584,53 @@ information about IOP and which sample application to use please read: Interoperability Test + STANDARD + EXPERT + GATT Table + Connecting to device… + Searching for device… + Discovering services… + Device disconnected. + Failed to discover services. + No device selected for the IOP test. + Bluetooth is turned off. + Couldn\'t find the device.\nMake sure it is powered on and nearby, then try again. + No services found on this device. + Expert Step Log + Test run started + Test run finished + Test %1$s: Scanning for firmware: %2$s + Test %1$s: Connecting to %2$s + Test %1$s: Connected to device + Test %1$s: Disconnected from device + Test %1$s: Discovered %2$d GATT services + Test %1$s: Reading %2$s + Test %1$s: Read %2$s = %3$s (packet size: %4$d bytes) + Test %1$s: Writing %2$s = %3$s (packet size: %4$d bytes) + Test %1$s: Write %2$s completed (packet size: %3$d bytes) + Test %1$s: Notification %2$s = %3$s (packet size: %4$d bytes) + Starting test %1$s: %2$s + Starting test %1$s + Starting test %1$s: %2$s + Test %1$s passed + Test %1$s failed + %1$s — %2$s + Starting test %1$s: OTA update + Test %1$s: Using bundled GBL file for OTA + Test %1$s: Selecting GBL file for OTA + Test %1$s: Selected OTA file: %2$s + Test %1$s: OTA file selection cancelled + Test %1$s: OTA progress: %2$d%% + Test %1$s: OTA packet size %2$d bytes (%3$s) + Test %1$s: OTA update completed (packet size: %2$d bytes) + OTA packet sizes — with ACK: %1$d bytes, without ACK: %2$d bytes (MTU %3$d) + Test %1$s: OTA update failed + acknowledged write + unacknowledged write + Throughput: %1$s (acceptable: %2$s) + Security step: %1$s + Expert logs will appear here during a test run.\n\nTap Run Test to start. + GATT server icon Dropdown arrow @@ -886,7 +938,7 @@ A circuit board (SoC) must be connected and running firmware with a title containing \"Bluetooth - SoC EFR32xG24 Dev Kit\" or \"Bluetooth - SoC EFR32xG26 Dev Kit + Apploader OTA DFU\" or \"Bluetooth - SoC Thunderboard EFR32BG22\". - Bluetooth - SoC Interoperability Test FreeRTOS + Bluetooth - SoC Interoperability Test\" or \"Bluetooth - SoC Interoperability Test FreeRTOS\" or \"Bluetooth - SoC Interoperability Test Micrium OS CONNECTED TO: %s TYPE: %s @@ -1712,9 +1764,9 @@ Select Initiator or Reflector Mode Choose between Phone as Initiator (distance measurement) or Phone as Reflector (Digital Key for Door Lock). Phone as Initiator - Phone measures distance to the EFRxG24 device. Use for testing and development of Channel Sounding ranging. + Phone measures distance to the EFR32xG24 device. Use for testing and development of Channel Sounding ranging. Phone as Reflector - Phone acts as a Digital Key for Door Lock. The door lock (EFRxG24) measures distance and unlocks when in range. + Phone acts as a Digital Key for Door Lock. The door lock (EFR32xG24) measures distance and unlocks when in range. Silabs Example Scanning for %s devices… No \"%s\" device found. Make sure the device is powered on and in range. diff --git a/mobile/src/main/res/values/styles.xml b/mobile/src/main/res/values/styles.xml index 0fe7beec..5212db3a 100644 --- a/mobile/src/main/res/values/styles.xml +++ b/mobile/src/main/res/values/styles.xml @@ -35,6 +35,14 @@ true + +