taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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 }