AndroidTalerUsbTransportTest.kt (6320B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2026 Taler Systems S.A. 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 package net.taler.merchantpos.devices 18 19 import android.app.Application 20 import android.content.Context 21 import android.hardware.usb.UsbDevice 22 import android.hardware.usb.UsbDeviceConnection 23 import android.hardware.usb.UsbManager 24 25 import androidx.test.core.app.ApplicationProvider 26 27 import org.junit.Assert.assertArrayEquals 28 import org.junit.Assert.assertEquals 29 import org.junit.Assert.assertFalse 30 import org.junit.Assert.assertTrue 31 import org.junit.Assert.fail 32 import org.junit.Before 33 import org.junit.Test 34 import org.junit.runner.RunWith 35 import org.robolectric.RobolectricTestRunner 36 import org.robolectric.Shadows.shadowOf 37 import org.robolectric.annotation.Config 38 import org.robolectric.annotation.Implementation 39 import org.robolectric.annotation.Implements 40 import org.robolectric.util.ReflectionHelpers 41 42 @RunWith(RobolectricTestRunner::class) 43 @Config(sdk = [28], application = Application::class, shadows = [RecordingUsbConnection::class]) 44 class AndroidTalerUsbTransportTest 45 { 46 private lateinit var manager: UsbManager 47 private lateinit var transport: AndroidTalerUsbTransport 48 49 @Before 50 fun setup() 51 { 52 val context = ApplicationProvider.getApplicationContext<Context>() 53 manager = context.getSystemService(Context.USB_SERVICE) as UsbManager 54 transport = AndroidTalerUsbTransport(manager) 55 RecordingUsbConnection.calls.clear() 56 RecordingUsbConnection.closed = 0 57 RecordingUsbConnection.result = null 58 RecordingUsbConnection.fail = false 59 } 60 61 @Test 62 fun `enumerates only the supported device and reports actual USB permission`() 63 { 64 attach("taler", permitted = false) 65 attach("other", vendor = 0x1234) 66 assertEquals(listOf(TalerUsbDevice("taler", false)), transport.devices()) 67 } 68 69 @Test 70 fun `sends exact vendor request and UTF8 bytes then closes connection`() 71 { 72 attach("taler") 73 val bytes = "taler://pay/example/é".toByteArray(Charsets.UTF_8) 74 assertTrue(transport.write(transport.devices().single(), bytes)) 75 val call = RecordingUsbConnection.calls.single() 76 assertEquals(listOf(0x40, 0, 0, 0, bytes.size, 1000), call.parameters) 77 assertArrayEquals(bytes, call.bytes) 78 assertEquals(1, RecordingUsbConnection.closed) 79 } 80 81 @Test 82 fun `short writes and negative responses fail and close the connection`() 83 { 84 attach("taler") 85 val device = transport.devices().single() 86 RecordingUsbConnection.result = 2 87 assertFalse(transport.write(device, ByteArray(5))) 88 RecordingUsbConnection.result = -1 89 assertFalse(transport.write(device, ByteArray(5))) 90 assertEquals(2, RecordingUsbConnection.closed) 91 } 92 93 @Test 94 fun `empty URL uses a zero length transfer without adding serial newline`() 95 { 96 attach("taler") 97 assertTrue(transport.write(transport.devices().single(), byteArrayOf())) 98 assertEquals( 99 0, 100 RecordingUsbConnection.calls 101 .single() 102 .bytes.size, 103 ) 104 } 105 106 @Test 107 fun `permission is rechecked immediately before opening the device`() 108 { 109 val attached = attach("taler") 110 val snapshot = transport.devices().single() 111 shadowOf(manager).addOrUpdateUsbDevice(attached, false) 112 assertFalse(transport.write(snapshot, byteArrayOf(1))) 113 assertTrue(RecordingUsbConnection.calls.isEmpty()) 114 } 115 116 @Test 117 fun `unplugged or unsupported devices are never opened`() 118 { 119 val attached = attach("taler") 120 val snapshot = transport.devices().single() 121 shadowOf(manager).removeUsbDevice(attached) 122 assertFalse(transport.write(snapshot, byteArrayOf(1))) 123 attach("taler", vendor = 0x1234) 124 assertFalse(transport.write(snapshot, byteArrayOf(1))) 125 assertTrue(RecordingUsbConnection.calls.isEmpty()) 126 } 127 128 @Test 129 fun `connection closes when a transfer throws`() 130 { 131 attach("taler") 132 RecordingUsbConnection.fail = true 133 try 134 { 135 transport.write(transport.devices().single(), byteArrayOf(1)) 136 fail("Expected transfer failure") 137 } 138 catch (_: IllegalStateException) 139 { 140 assertEquals(1, RecordingUsbConnection.closed) 141 } 142 } 143 144 private fun attach( 145 name: String, 146 vendor: Int = 0xd00d, 147 permitted: Boolean = true, 148 ): UsbDevice 149 { 150 val device = ReflectionHelpers.newInstance(UsbDevice::class.java) 151 ReflectionHelpers.setField(device, "mName", name) 152 ReflectionHelpers.setField(device, "mVendorId", vendor) 153 ReflectionHelpers.setField(device, "mProductId", 0x8f71) 154 shadowOf(manager).addOrUpdateUsbDevice(device, permitted) 155 return device 156 } 157 } 158 159 @Implements(UsbDeviceConnection::class) 160 class RecordingUsbConnection 161 { 162 data class Call( 163 val parameters: List<Int>, 164 val bytes: ByteArray, 165 ) 166 167 companion object 168 { 169 val calls = mutableListOf<Call>() 170 var closed = 0 171 var result: Int? = null 172 var fail = false 173 } 174 175 @Implementation 176 fun controlTransfer( 177 type: Int, 178 request: Int, 179 value: Int, 180 index: Int, 181 buffer: ByteArray, 182 length: Int, 183 timeout: Int, 184 ): Int 185 { 186 if (fail) throw IllegalStateException("USB disconnected") 187 calls += Call(listOf(type, request, value, index, length, timeout), buffer.copyOf(length)) 188 return result ?: length 189 } 190 191 @Implementation 192 fun close() 193 { 194 closed++ 195 } 196 }