From 582667695333d978fe8ee90884b6d3d1a8006035 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:34:20 -0700 Subject: [PATCH 1/4] fix: Import cloud-backed PKPASS files off the main thread Fixes #2464 --- .../java/protect/card_locker/MainActivity.kt | 35 ++- .../java/protect/card_locker/ScanActivity.kt | 26 ++ .../main/java/protect/card_locker/Utils.java | 24 +- .../protect/card_locker/MainActivityTest.java | 178 ++++++++++++++ .../protect/card_locker/ScanActivityTest.kt | 222 ++++++++++++++++++ 5 files changed, 467 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/protect/card_locker/ScanActivityTest.kt diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt index 04692499a9..ce96feeb5e 100644 --- a/app/src/main/java/protect/card_locker/MainActivity.kt +++ b/app/src/main/java/protect/card_locker/MainActivity.kt @@ -23,7 +23,9 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView +import androidx.core.content.edit import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout @@ -40,7 +42,9 @@ import protect.card_locker.preferences.SettingsActivity import protect.card_locker.wearos.WearSyncPermissionRequester import java.io.UnsupportedEncodingException import java.util.concurrent.atomic.AtomicInteger -import androidx.core.content.edit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { private lateinit var binding: MainActivityBinding @@ -533,14 +537,17 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { "application/vnd-com.apple.pkpass" ).contains(receivedType) ) { - parseResultList = Utils.retrieveBarcodesFromPkPass(this, data) + importPkpass(data, false) + return } else if (receivedType == "application/vnd.espass-espass") { // FIXME: espass is not pkpass // However, several users stated in https://github.com/CatimaLoyalty/Android/issues/2197 that the formats are extremely similar to the point they could rename an .espass file to .pkpass and have it imported // So it makes sense to "unofficially" treat it as a PKPASS for now, even though not completely correct - parseResultList = Utils.retrieveBarcodesFromPkPass(this, data) + importPkpass(data, false) + return } else if (receivedType == "application/vnd.apple.pkpasses") { - parseResultList = Utils.retrieveBarcodesFromPkPasses(this, data) + importPkpass(data, true) + return } else { Log.e(TAG, "Wrong mime-type") return @@ -556,6 +563,26 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { processParseResultList(parseResultList) } + private fun importPkpass(data: Uri?, multiplePasses: Boolean) { + lifecycleScope.launch(Dispatchers.IO) { + val parseResultList = if (multiplePasses) { + Utils.retrieveBarcodesFromPkPasses(this@MainActivity, data) + } else { + Utils.retrieveBarcodesFromPkPass(this@MainActivity, data) + } + + withContext(Dispatchers.Main) { + if (parseResultList.isEmpty()) { + Toast.makeText(this@MainActivity, R.string.errorReadingFile, Toast.LENGTH_LONG).show() + finish() + return@withContext + } + + processParseResultList(parseResultList) + } + } + } + private fun extractIntentFields(intent: Intent) { onSharedIntent(intent) } diff --git a/app/src/main/java/protect/card_locker/ScanActivity.kt b/app/src/main/java/protect/card_locker/ScanActivity.kt index 952dac3c30..6d68382fcd 100644 --- a/app/src/main/java/protect/card_locker/ScanActivity.kt +++ b/app/src/main/java/protect/card_locker/ScanActivity.kt @@ -30,6 +30,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.zxing.DecodeHintType import com.google.zxing.ResultPoint @@ -37,6 +38,9 @@ import com.journeyapps.barcodescanner.BarcodeCallback import com.journeyapps.barcodescanner.BarcodeResult import com.journeyapps.barcodescanner.CaptureManager import com.journeyapps.barcodescanner.DecoratedBarcodeView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import protect.card_locker.databinding.CustomBarcodeScannerBinding import protect.card_locker.databinding.ScanActivityBinding @@ -322,6 +326,24 @@ class ScanActivity : CatimaAppCompatActivity() { private fun handleActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { super.onActivityResult(resultCode, resultCode, intent) + if (requestCode == Utils.BARCODE_IMPORT_FROM_PKPASS_FILE && resultCode == RESULT_OK) { + lifecycleScope.launch(Dispatchers.IO) { + val parseResultList = + Utils.parseSetBarcodeActivityResult(requestCode, resultCode, intent, this@ScanActivity) + + withContext(Dispatchers.Main) { + if (parseResultList.isEmpty()) { + Toast.makeText(this@ScanActivity, R.string.errorReadingFile, Toast.LENGTH_LONG).show() + setScannerActive(true) + return@withContext + } + + processParseResultList(parseResultList) + } + } + return + } + val parseResultList: List = Utils.parseSetBarcodeActivityResult(requestCode, resultCode, intent, this) @@ -331,6 +353,10 @@ class ScanActivity : CatimaAppCompatActivity() { } + processParseResultList(parseResultList) + } + + private fun processParseResultList(parseResultList: List) { Utils.makeUserChooseParseResultFromList( this, parseResultList, diff --git a/app/src/main/java/protect/card_locker/Utils.java b/app/src/main/java/protect/card_locker/Utils.java index 8519f22dbe..71a6441703 100644 --- a/app/src/main/java/protect/card_locker/Utils.java +++ b/app/src/main/java/protect/card_locker/Utils.java @@ -181,7 +181,6 @@ static public List retrieveBarcodesFromPkPass(Context context, Uri Log.i(TAG, "Received Pkpass file with possible barcode"); if (uri == null) { Log.e(TAG, "Pkpass did not contain any data"); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); return new ArrayList<>(); } @@ -190,7 +189,6 @@ static public List retrieveBarcodesFromPkPass(Context context, Uri pkpassParser = new PkpassParser(context, uri); } catch (Exception e) { Log.e(TAG, "Error reading pkpass file", e); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); return new ArrayList<>(); } @@ -200,7 +198,6 @@ static public List retrieveBarcodesFromPkPass(Context context, Uri return Collections.singletonList(new ParseResult(ParseResultType.FULL, pkpassParser.toLoyaltyCard(null))); } catch (Exception e) { Log.e(TAG, "Error calling toLoyaltyCard on pkpass file", e); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); return new ArrayList<>(); } } @@ -212,7 +209,6 @@ static public List retrieveBarcodesFromPkPass(Context context, Uri parseResult = new ParseResult(ParseResultType.FULL, pkpassParser.toLoyaltyCard(locale)); } catch (Exception e) { Log.e(TAG, "Error calling toLoyaltyCard on pkpass file", e); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); return new ArrayList<>(); } parseResult.setNote(locale); @@ -226,7 +222,6 @@ static public List retrieveBarcodesFromPkPasses(Context context, Ur Log.i(TAG, "Received Pkpasses file with possible barcode"); if (uri == null) { Log.e(TAG, "Pkpasses did not contain any data"); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); return new ArrayList<>(); } @@ -235,7 +230,6 @@ static public List retrieveBarcodesFromPkPasses(Context context, Ur pkpassesParser = new PkpassesParser(context, uri); } catch (Exception e) { Log.e(TAG, "Error reading pkpasses file", e); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); return new ArrayList<>(); } @@ -249,7 +243,6 @@ static public List retrieveBarcodesFromPkPasses(Context context, Ur parseResult = new ParseResult(ParseResultType.FULL, pkpassParser.toLoyaltyCard(null)); } catch (Exception e) { Log.e(TAG, "Error calling toLoyaltyCard on pkpass file", e); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); return new ArrayList<>(); } parseResult.setNote(String.format(context.getString(R.string.cardWithNumber), i+1)); @@ -260,7 +253,6 @@ static public List retrieveBarcodesFromPkPasses(Context context, Ur parseResult = new ParseResult(ParseResultType.FULL, pkpassParser.toLoyaltyCard(locale)); } catch (Exception e) { Log.e(TAG, "Error calling toLoyaltyCard on pkpass file", e); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); return new ArrayList<>(); } parseResult.setNote(String.format(context.getString(R.string.cardWithNumberAndLocale), i+1, locale)); @@ -365,19 +357,23 @@ static public List parseSetBarcodeActivityResult(int requestCode, i } if (requestCode == Utils.BARCODE_IMPORT_FROM_PKPASS_FILE) { - Uri intentData = intent.getData(); + Uri intentData = intent != null ? intent.getData() : null; if (intentData == null) { Log.e(TAG, "Uri did not contain any data"); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); return new ArrayList<>(); } - if (Objects.equals(context.getContentResolver().getType(intentData), "application/vnd.apple.pkpasses")) { - return retrieveBarcodesFromPkPasses(context, intentData); - } + try { + if (Objects.equals(context.getContentResolver().getType(intentData), "application/vnd.apple.pkpasses")) { + return retrieveBarcodesFromPkPasses(context, intentData); + } - return retrieveBarcodesFromPkPass(context, intentData); + return retrieveBarcodesFromPkPass(context, intentData); + } catch (Exception e) { + Log.e(TAG, "Error reading pkpass file", e); + return new ArrayList<>(); + } } if (requestCode == Utils.BARCODE_SCAN || requestCode == Utils.SELECT_BARCODE_REQUEST) { diff --git a/app/src/test/java/protect/card_locker/MainActivityTest.java b/app/src/test/java/protect/card_locker/MainActivityTest.java index 503eac7570..80c6cd7053 100644 --- a/app/src/test/java/protect/card_locker/MainActivityTest.java +++ b/app/src/test/java/protect/card_locker/MainActivityTest.java @@ -2,16 +2,21 @@ import static android.os.Looper.getMainLooper; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.robolectric.Shadows.shadowOf; import android.app.Activity; import android.content.ComponentName; +import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Configuration; import android.database.sqlite.SQLiteDatabase; import android.graphics.Color; +import android.net.Uri; +import android.os.Looper; import android.view.Menu; import android.view.MenuItem; import android.view.View; @@ -30,17 +35,190 @@ import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.android.controller.ActivityController; +import org.robolectric.annotation.LooperMode; import org.robolectric.shadows.ShadowActivity; +import org.robolectric.shadows.ShadowContentResolver; +import org.robolectric.shadows.ShadowToast; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; @RunWith(RobolectricTestRunner.class) public class MainActivityTest { private SharedPreferences prefs; + @Test + @LooperMode(LooperMode.Mode.PAUSED) + public void pkpassSharedIntentReadsOffMainThreadAndProcessesOnMainThread() throws Exception { + Uri uri = Uri.parse("content://test/cloud-backed.pkpass"); + CountDownLatch readStarted = new CountDownLatch(1); + CountDownLatch streamClosed = new CountDownLatch(1); + AtomicReference readLooper = new AtomicReference<>(); + InputStream pkpass = getClass().getResourceAsStream( + "pkpass/DCBLN24/DCBLN24-QLUKT-1-passbook.pkpass"); + assertNotNull(pkpass); + new ShadowContentResolver().registerInputStream( + uri, new RecordingInputStream(pkpass, readStarted, streamClosed, readLooper)); + + Intent intent = new Intent(Intent.ACTION_VIEW, uri) + .setType("application/vnd.apple.pkpass"); + ActivityController controller = + Robolectric.buildActivity(MainActivity.class, intent).create().start().resume().visible(); + MainActivity activity = controller.get(); + + assertTrue(readStarted.await(5, TimeUnit.SECONDS)); + assertTrue(readLooper.get() != getMainLooper()); + assertTrue(streamClosed.await(5, TimeUnit.SECONDS)); + assertNull(shadowOf(activity).peekNextStartedActivity()); + + waitFor(() -> shadowOf(activity).peekNextStartedActivity() != null); + Intent editIntent = shadowOf(activity).getNextStartedActivity(); + assertEquals(LoyaltyCardEditActivity.class.getName(), editIntent.getComponent().getClassName()); + assertNotNull(editIntent.getExtras()); + controller.destroy(); + } + + @Test + @LooperMode(LooperMode.Mode.PAUSED) + public void destroyingActivityCancelsPendingPkpassResult() throws Exception { + Uri uri = Uri.parse("content://test/pending-cloud-backed.pkpass"); + CountDownLatch readStarted = new CountDownLatch(1); + CountDownLatch releaseRead = new CountDownLatch(1); + CountDownLatch streamClosed = new CountDownLatch(1); + InputStream pkpass = getClass().getResourceAsStream( + "pkpass/DCBLN24/DCBLN24-QLUKT-1-passbook.pkpass"); + assertNotNull(pkpass); + new ShadowContentResolver().registerInputStream( + uri, new BlockingInputStream(pkpass, readStarted, releaseRead, streamClosed)); + + Intent intent = new Intent(Intent.ACTION_VIEW, uri) + .setType("application/vnd.apple.pkpass"); + ActivityController controller = + Robolectric.buildActivity(MainActivity.class, intent).create().start().resume(); + MainActivity activity = controller.get(); + + assertTrue(readStarted.await(5, TimeUnit.SECONDS)); + controller.destroy(); + releaseRead.countDown(); + assertTrue(streamClosed.await(5, TimeUnit.SECONDS)); + shadowOf(getMainLooper()).idle(); + + assertNull(shadowOf(activity).peekNextStartedActivity()); + assertFalse(ShadowToast.showedToast(activity.getString(R.string.errorReadingFile))); + } + + private static void waitFor(BooleanSupplier condition) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) { + shadowOf(getMainLooper()).idle(); + Thread.sleep(10); + } + shadowOf(getMainLooper()).idle(); + assertTrue("Timed out waiting for asynchronous activity work", condition.getAsBoolean()); + } + + private static class RecordingInputStream extends FilterInputStream { + private final CountDownLatch readStarted; + private final CountDownLatch streamClosed; + private final AtomicReference readLooper; + + RecordingInputStream( + InputStream inputStream, + CountDownLatch readStarted, + CountDownLatch streamClosed, + AtomicReference readLooper) { + super(inputStream); + this.readStarted = readStarted; + this.streamClosed = streamClosed; + this.readLooper = readLooper; + } + + private void recordRead() { + readLooper.compareAndSet(null, Looper.myLooper()); + readStarted.countDown(); + } + + @Override + public int read() throws IOException { + recordRead(); + return super.read(); + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + recordRead(); + return super.read(buffer, offset, length); + } + + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + streamClosed.countDown(); + } + } + } + + private static class BlockingInputStream extends FilterInputStream { + private final CountDownLatch readStarted; + private final CountDownLatch releaseRead; + private final CountDownLatch streamClosed; + + BlockingInputStream( + InputStream inputStream, + CountDownLatch readStarted, + CountDownLatch releaseRead, + CountDownLatch streamClosed) { + super(inputStream); + this.readStarted = readStarted; + this.releaseRead = releaseRead; + this.streamClosed = streamClosed; + } + + private void awaitRelease() throws IOException { + readStarted.countDown(); + try { + if (!releaseRead.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to release provider stream"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IOException(exception); + } + } + + @Override + public int read() throws IOException { + awaitRelease(); + return super.read(); + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + awaitRelease(); + return super.read(buffer, offset, length); + } + + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + streamClosed.countDown(); + } + } + } + @Test public void initiallyNoLoyaltyCards() { Activity activity = Robolectric.setupActivity(MainActivity.class); diff --git a/app/src/test/java/protect/card_locker/ScanActivityTest.kt b/app/src/test/java/protect/card_locker/ScanActivityTest.kt new file mode 100644 index 0000000000..c5aaaa1887 --- /dev/null +++ b/app/src/test/java/protect/card_locker/ScanActivityTest.kt @@ -0,0 +1,222 @@ +package protect.card_locker + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Looper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.LooperMode +import org.robolectric.shadows.ShadowContentResolver +import org.robolectric.shadows.ShadowToast +import java.io.FilterInputStream +import java.io.IOException +import java.io.InputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +class ScanActivityTest { + @Before + fun setUp() { + ShadowToast.reset() + } + + @Test + fun pkpassPickerReadsOffMainThreadAndReturnsResultOnMainThread() { + val uri = Uri.parse("content://test/picker-cloud-backed.pkpass") + val readStarted = CountDownLatch(1) + val streamClosed = CountDownLatch(1) + val readLooper = AtomicReference() + val pkpass = javaClass.getResourceAsStream( + "pkpass/DCBLN24/DCBLN24-QLUKT-1-passbook.pkpass" + ) + assertNotNull(pkpass) + ShadowContentResolver().registerInputStream( + uri, + RecordingInputStream(pkpass, readStarted, streamClosed, readLooper) + ) + val controller = createActivity() + val activity = controller.get() + + handlePkpassResult(activity, Intent().setData(uri)) + + assertTrue(readStarted.await(5, TimeUnit.SECONDS)) + assertTrue(readLooper.get() !== Looper.getMainLooper()) + assertTrue(streamClosed.await(5, TimeUnit.SECONDS)) + assertEquals(Activity.RESULT_CANCELED, shadowOf(activity).resultCode) + + waitFor { shadowOf(activity).resultCode == Activity.RESULT_OK } + assertNotNull(shadowOf(activity).resultIntent) + assertTrue(activity.isFinishing) + controller.destroy() + } + + @Test + fun providerFailureShowsOneErrorAndReenablesScanner() { + val uri = Uri.parse("content://test/unavailable-cloud-backed.pkpass") + ShadowContentResolver().registerInputStream(uri, FailingInputStream()) + val controller = createActivity() + val activity = controller.get() + setScannerActive(activity, false) + val initialToastCount = ShadowToast.shownToastCount() + + handlePkpassResult(activity, Intent().setData(uri)) + + waitFor { ShadowToast.shownToastCount() > initialToastCount } + assertEquals(initialToastCount + 1, ShadowToast.shownToastCount()) + assertEquals(activity.getString(R.string.errorReadingFile), ShadowToast.getTextOfLatestToast()) + assertTrue(isScannerActive(activity)) + assertFalse(activity.isFinishing) + controller.destroy() + } + + @Test + fun destroyingActivityCancelsPendingPkpassResult() { + val uri = Uri.parse("content://test/pending-picker-cloud-backed.pkpass") + val readStarted = CountDownLatch(1) + val releaseRead = CountDownLatch(1) + val streamClosed = CountDownLatch(1) + val pkpass = javaClass.getResourceAsStream( + "pkpass/DCBLN24/DCBLN24-QLUKT-1-passbook.pkpass" + ) + assertNotNull(pkpass) + ShadowContentResolver().registerInputStream( + uri, + BlockingInputStream(pkpass, readStarted, releaseRead, streamClosed) + ) + val controller = createActivity() + val activity = controller.get() + + handlePkpassResult(activity, Intent().setData(uri)) + assertTrue(readStarted.await(5, TimeUnit.SECONDS)) + controller.destroy() + releaseRead.countDown() + assertTrue(streamClosed.await(5, TimeUnit.SECONDS)) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(Activity.RESULT_CANCELED, shadowOf(activity).resultCode) + assertFalse(ShadowToast.showedToast(activity.getString(R.string.errorReadingFile))) + } + + private fun createActivity(): ActivityController = + Robolectric.buildActivity(ScanActivity::class.java).create().start().resume().visible() + + private fun handlePkpassResult(activity: ScanActivity, intent: Intent) { + ScanActivity::class.java.getDeclaredMethod( + "handleActivityResult", + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + Intent::class.java + ).apply { isAccessible = true }.invoke( + activity, + Utils.BARCODE_IMPORT_FROM_PKPASS_FILE, + Activity.RESULT_OK, + intent + ) + } + + private fun setScannerActive(activity: ScanActivity, active: Boolean) { + ScanActivity::class.java.getDeclaredMethod( + "setScannerActive", + Boolean::class.javaPrimitiveType + ).apply { isAccessible = true }.invoke(activity, active) + } + + private fun isScannerActive(activity: ScanActivity): Boolean = + ScanActivity::class.java.getDeclaredField("mScannerActive") + .apply { isAccessible = true } + .getBoolean(activity) + + private fun waitFor(condition: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (!condition() && System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(10) + } + shadowOf(Looper.getMainLooper()).idle() + assertTrue("Timed out waiting for asynchronous activity work", condition()) + } + + private class RecordingInputStream( + inputStream: InputStream, + private val readStarted: CountDownLatch, + private val streamClosed: CountDownLatch, + private val readLooper: AtomicReference + ) : FilterInputStream(inputStream) { + private fun recordRead() { + readLooper.compareAndSet(null, Looper.myLooper()) + readStarted.countDown() + } + + override fun read(): Int { + recordRead() + return super.read() + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + recordRead() + return super.read(buffer, offset, length) + } + + override fun close() { + try { + super.close() + } finally { + streamClosed.countDown() + } + } + } + + private class BlockingInputStream( + inputStream: InputStream, + private val readStarted: CountDownLatch, + private val releaseRead: CountDownLatch, + private val streamClosed: CountDownLatch + ) : FilterInputStream(inputStream) { + private fun awaitRelease() { + readStarted.countDown() + try { + if (!releaseRead.await(5, TimeUnit.SECONDS)) { + throw IOException("Timed out waiting to release provider stream") + } + } catch (exception: InterruptedException) { + Thread.currentThread().interrupt() + throw IOException(exception) + } + } + + override fun read(): Int { + awaitRelease() + return super.read() + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + awaitRelease() + return super.read(buffer, offset, length) + } + + override fun close() { + try { + super.close() + } finally { + streamClosed.countDown() + } + } + } + + private class FailingInputStream : InputStream() { + override fun read(): Int = throw IOException("Remote provider unavailable") + } +} From 39a84b70ee57ad918e6ae6c16f77ee636c23274a Mon Sep 17 00:00:00 2001 From: mvanhorn Date: Tue, 25 Aug 2026 05:01:20 -0700 Subject: [PATCH 2/4] Address review: move all import parsing off the main thread, restore failure toasts - Route image/PDF/pkpass/espass imports through one background importFile - Restore errorReadingFile / errorReadingImage / noBarcodeFound toasts via a main-looper-marshalling showToast helper - Replace fixed-timeout awaits in the activity tests with a waitFor predicate --- .../java/protect/card_locker/MainActivity.kt | 63 +++++++------------ .../java/protect/card_locker/ScanActivity.kt | 33 +++------- .../main/java/protect/card_locker/Utils.java | 33 ++++++++-- .../protect/card_locker/MainActivityTest.java | 4 +- .../protect/card_locker/ScanActivityTest.kt | 4 +- 5 files changed, 63 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt index ce96feeb5e..91ced33e3f 100644 --- a/app/src/main/java/protect/card_locker/MainActivity.kt +++ b/app/src/main/java/protect/card_locker/MainActivity.kt @@ -506,13 +506,11 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { return } - val parseResultList: MutableList? - // Check for shared text if (receivedAction == Intent.ACTION_SEND && receivedType == "text/plain") { val loyaltyCard = LoyaltyCard() loyaltyCard.setCardId(intent.getStringExtra(Intent.EXTRA_TEXT)!!) - parseResultList = mutableListOf(ParseResult(ParseResultType.BARCODE_ONLY, loyaltyCard)) + processParseResultList(mutableListOf(ParseResult(ParseResultType.BARCODE_ONLY, loyaltyCard))) } else { // Parse whatever file was sent, regardless of opening or sharing val data: Uri? = when (receivedAction) { @@ -528,52 +526,37 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { } } - if (receivedType.startsWith("image/")) { - parseResultList = Utils.retrieveBarcodesFromImage(this, data) - } else if (receivedType == "application/pdf") { - parseResultList = Utils.retrieveBarcodesFromPdf(this, data) - } else if (mutableListOf( + importFile(data, receivedType) + } + } + + private fun importFile(data: Uri?, receivedType: String) { + lifecycleScope.launch(Dispatchers.IO) { + val parseResultList: MutableList = when { + receivedType.startsWith("image/") -> + Utils.retrieveBarcodesFromImage(this@MainActivity, data) + receivedType == "application/pdf" -> + Utils.retrieveBarcodesFromPdf(this@MainActivity, data) + mutableListOf( "application/vnd.apple.pkpass", "application/vnd-com.apple.pkpass" - ).contains(receivedType) - ) { - importPkpass(data, false) - return - } else if (receivedType == "application/vnd.espass-espass") { + ).contains(receivedType) -> + Utils.retrieveBarcodesFromPkPass(this@MainActivity, data) // FIXME: espass is not pkpass // However, several users stated in https://github.com/CatimaLoyalty/Android/issues/2197 that the formats are extremely similar to the point they could rename an .espass file to .pkpass and have it imported // So it makes sense to "unofficially" treat it as a PKPASS for now, even though not completely correct - importPkpass(data, false) - return - } else if (receivedType == "application/vnd.apple.pkpasses") { - importPkpass(data, true) - return - } else { - Log.e(TAG, "Wrong mime-type") - return - } - } - - // Give up if we should parse but there is nothing to parse - if (parseResultList == null || parseResultList.isEmpty()) { - finish() - return - } - - processParseResultList(parseResultList) - } - - private fun importPkpass(data: Uri?, multiplePasses: Boolean) { - lifecycleScope.launch(Dispatchers.IO) { - val parseResultList = if (multiplePasses) { - Utils.retrieveBarcodesFromPkPasses(this@MainActivity, data) - } else { - Utils.retrieveBarcodesFromPkPass(this@MainActivity, data) + receivedType == "application/vnd.espass-espass" -> + Utils.retrieveBarcodesFromPkPass(this@MainActivity, data) + receivedType == "application/vnd.apple.pkpasses" -> + Utils.retrieveBarcodesFromPkPasses(this@MainActivity, data) + else -> { + Log.e(TAG, "Wrong mime-type") + return@launch + } } withContext(Dispatchers.Main) { if (parseResultList.isEmpty()) { - Toast.makeText(this@MainActivity, R.string.errorReadingFile, Toast.LENGTH_LONG).show() finish() return@withContext } diff --git a/app/src/main/java/protect/card_locker/ScanActivity.kt b/app/src/main/java/protect/card_locker/ScanActivity.kt index 6d68382fcd..fad13b96dc 100644 --- a/app/src/main/java/protect/card_locker/ScanActivity.kt +++ b/app/src/main/java/protect/card_locker/ScanActivity.kt @@ -326,34 +326,19 @@ class ScanActivity : CatimaAppCompatActivity() { private fun handleActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { super.onActivityResult(resultCode, resultCode, intent) - if (requestCode == Utils.BARCODE_IMPORT_FROM_PKPASS_FILE && resultCode == RESULT_OK) { - lifecycleScope.launch(Dispatchers.IO) { - val parseResultList = - Utils.parseSetBarcodeActivityResult(requestCode, resultCode, intent, this@ScanActivity) - - withContext(Dispatchers.Main) { - if (parseResultList.isEmpty()) { - Toast.makeText(this@ScanActivity, R.string.errorReadingFile, Toast.LENGTH_LONG).show() - setScannerActive(true) - return@withContext - } + lifecycleScope.launch(Dispatchers.IO) { + val parseResultList = + Utils.parseSetBarcodeActivityResult(requestCode, resultCode, intent, this@ScanActivity) - processParseResultList(parseResultList) + withContext(Dispatchers.Main) { + if (parseResultList.isEmpty()) { + setScannerActive(true) + return@withContext } - } - return - } - - val parseResultList: List = - Utils.parseSetBarcodeActivityResult(requestCode, resultCode, intent, this) - if (parseResultList.isEmpty()) { - setScannerActive(true) - return + processParseResultList(parseResultList) + } } - - - processParseResultList(parseResultList) } private fun processParseResultList(parseResultList: List) { diff --git a/app/src/main/java/protect/card_locker/Utils.java b/app/src/main/java/protect/card_locker/Utils.java index 71a6441703..096b70bfdf 100644 --- a/app/src/main/java/protect/card_locker/Utils.java +++ b/app/src/main/java/protect/card_locker/Utils.java @@ -19,6 +19,8 @@ import android.hardware.camera2.CameraManager; import android.net.Uri; import android.os.Build; +import android.os.Handler; +import android.os.Looper; import android.os.ParcelFileDescriptor; import android.provider.MediaStore; import android.text.Layout; @@ -118,6 +120,15 @@ public class Utils { static final int BITMAP_SIZE_SMALL = 512; static final int BITMAP_SIZE_BIG = 1600; + private static void showToast(Context context, int message) { + if (Looper.myLooper() == Looper.getMainLooper()) { + Toast.makeText(context, message, Toast.LENGTH_LONG).show(); + } else { + new Handler(Looper.getMainLooper()).post( + () -> Toast.makeText(context, message, Toast.LENGTH_LONG).show()); + } + } + static public LetterBitmap generateIcon(Context context, LoyaltyCard loyaltyCard, boolean forShortcut) { return generateIcon(context, loyaltyCard.store, loyaltyCard.headerColor, forShortcut); } @@ -153,7 +164,7 @@ static public List retrieveBarcodesFromImage(Context context, Uri u if (uri == null) { Log.e(TAG, "Uri did not contain any data"); - Toast.makeText(context, R.string.errorReadingImage, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingImage); return new ArrayList<>(); } @@ -163,7 +174,7 @@ static public List retrieveBarcodesFromImage(Context context, Uri u } catch (IOException e) { Log.e(TAG, "Error getting data from image file"); e.printStackTrace(); - Toast.makeText(context, R.string.errorReadingImage, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingImage); return new ArrayList<>(); } @@ -171,7 +182,7 @@ static public List retrieveBarcodesFromImage(Context context, Uri u if (barcodesFromBitmap.isEmpty()) { Log.i(TAG, "No barcode found in image file"); - Toast.makeText(context, R.string.noBarcodeFound, Toast.LENGTH_LONG).show(); + showToast(context, R.string.noBarcodeFound); } return barcodesFromBitmap; @@ -181,6 +192,7 @@ static public List retrieveBarcodesFromPkPass(Context context, Uri Log.i(TAG, "Received Pkpass file with possible barcode"); if (uri == null) { Log.e(TAG, "Pkpass did not contain any data"); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } @@ -189,6 +201,7 @@ static public List retrieveBarcodesFromPkPass(Context context, Uri pkpassParser = new PkpassParser(context, uri); } catch (Exception e) { Log.e(TAG, "Error reading pkpass file", e); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } @@ -198,6 +211,7 @@ static public List retrieveBarcodesFromPkPass(Context context, Uri return Collections.singletonList(new ParseResult(ParseResultType.FULL, pkpassParser.toLoyaltyCard(null))); } catch (Exception e) { Log.e(TAG, "Error calling toLoyaltyCard on pkpass file", e); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } } @@ -209,6 +223,7 @@ static public List retrieveBarcodesFromPkPass(Context context, Uri parseResult = new ParseResult(ParseResultType.FULL, pkpassParser.toLoyaltyCard(locale)); } catch (Exception e) { Log.e(TAG, "Error calling toLoyaltyCard on pkpass file", e); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } parseResult.setNote(locale); @@ -222,6 +237,7 @@ static public List retrieveBarcodesFromPkPasses(Context context, Ur Log.i(TAG, "Received Pkpasses file with possible barcode"); if (uri == null) { Log.e(TAG, "Pkpasses did not contain any data"); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } @@ -230,6 +246,7 @@ static public List retrieveBarcodesFromPkPasses(Context context, Ur pkpassesParser = new PkpassesParser(context, uri); } catch (Exception e) { Log.e(TAG, "Error reading pkpasses file", e); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } @@ -243,6 +260,7 @@ static public List retrieveBarcodesFromPkPasses(Context context, Ur parseResult = new ParseResult(ParseResultType.FULL, pkpassParser.toLoyaltyCard(null)); } catch (Exception e) { Log.e(TAG, "Error calling toLoyaltyCard on pkpass file", e); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } parseResult.setNote(String.format(context.getString(R.string.cardWithNumber), i+1)); @@ -253,6 +271,7 @@ static public List retrieveBarcodesFromPkPasses(Context context, Ur parseResult = new ParseResult(ParseResultType.FULL, pkpassParser.toLoyaltyCard(locale)); } catch (Exception e) { Log.e(TAG, "Error calling toLoyaltyCard on pkpass file", e); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } parseResult.setNote(String.format(context.getString(R.string.cardWithNumberAndLocale), i+1, locale)); @@ -270,7 +289,7 @@ static public List retrieveBarcodesFromPdf(Context context, Uri uri Log.i(TAG, "Received PDF file with possible barcode"); if (uri == null) { Log.e(TAG, "Uri did not contain any data"); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } @@ -307,7 +326,7 @@ static public List retrieveBarcodesFromPdf(Context context, Uri uri } } catch (IOException e) { Log.e(TAG, "Error reading PDF file", e); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingFile); } finally { // Resource handling if (renderer != null) { @@ -324,7 +343,7 @@ static public List retrieveBarcodesFromPdf(Context context, Uri uri if (barcodesFromPdfPages.isEmpty()) { Log.i(TAG, "No barcode found in pdf file"); - Toast.makeText(context, R.string.noBarcodeFound, Toast.LENGTH_LONG).show(); + showToast(context, R.string.noBarcodeFound); } return barcodesFromPdfPages; } @@ -361,6 +380,7 @@ static public List parseSetBarcodeActivityResult(int requestCode, i if (intentData == null) { Log.e(TAG, "Uri did not contain any data"); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } @@ -372,6 +392,7 @@ static public List parseSetBarcodeActivityResult(int requestCode, i return retrieveBarcodesFromPkPass(context, intentData); } catch (Exception e) { Log.e(TAG, "Error reading pkpass file", e); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } } diff --git a/app/src/test/java/protect/card_locker/MainActivityTest.java b/app/src/test/java/protect/card_locker/MainActivityTest.java index 80c6cd7053..93b94effc2 100644 --- a/app/src/test/java/protect/card_locker/MainActivityTest.java +++ b/app/src/test/java/protect/card_locker/MainActivityTest.java @@ -75,7 +75,7 @@ public void pkpassSharedIntentReadsOffMainThreadAndProcessesOnMainThread() throw Robolectric.buildActivity(MainActivity.class, intent).create().start().resume().visible(); MainActivity activity = controller.get(); - assertTrue(readStarted.await(5, TimeUnit.SECONDS)); + waitFor(() -> readStarted.getCount() == 0); assertTrue(readLooper.get() != getMainLooper()); assertTrue(streamClosed.await(5, TimeUnit.SECONDS)); assertNull(shadowOf(activity).peekNextStartedActivity()); @@ -106,7 +106,7 @@ public void destroyingActivityCancelsPendingPkpassResult() throws Exception { Robolectric.buildActivity(MainActivity.class, intent).create().start().resume(); MainActivity activity = controller.get(); - assertTrue(readStarted.await(5, TimeUnit.SECONDS)); + waitFor(() -> readStarted.getCount() == 0); controller.destroy(); releaseRead.countDown(); assertTrue(streamClosed.await(5, TimeUnit.SECONDS)); diff --git a/app/src/test/java/protect/card_locker/ScanActivityTest.kt b/app/src/test/java/protect/card_locker/ScanActivityTest.kt index c5aaaa1887..4409ffcf0e 100644 --- a/app/src/test/java/protect/card_locker/ScanActivityTest.kt +++ b/app/src/test/java/protect/card_locker/ScanActivityTest.kt @@ -52,7 +52,7 @@ class ScanActivityTest { handlePkpassResult(activity, Intent().setData(uri)) - assertTrue(readStarted.await(5, TimeUnit.SECONDS)) + waitFor { readStarted.count == 0L } assertTrue(readLooper.get() !== Looper.getMainLooper()) assertTrue(streamClosed.await(5, TimeUnit.SECONDS)) assertEquals(Activity.RESULT_CANCELED, shadowOf(activity).resultCode) @@ -100,7 +100,7 @@ class ScanActivityTest { val activity = controller.get() handlePkpassResult(activity, Intent().setData(uri)) - assertTrue(readStarted.await(5, TimeUnit.SECONDS)) + waitFor { readStarted.count == 0L } controller.destroy() releaseRead.countDown() assertTrue(streamClosed.await(5, TimeUnit.SECONDS)) From c70a39697f54f9a6d16908a24c399b29a5c458cb Mon Sep 17 00:00:00 2001 From: mvanhorn Date: Wed, 26 Aug 2026 05:14:21 -0700 Subject: [PATCH 3/4] Start the import coroutine on the main dispatcher, hop to IO for the read launch(Dispatchers.IO) starts the coroutine on a background thread immediately, so a paused Robolectric main looper never drives it and the activity tests time out. Launch on the lifecycle scope's default (main) dispatcher and wrap only the blocking read in withContext(Dispatchers.IO). --- .../java/protect/card_locker/MainActivity.kt | 56 +++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt index 91ced33e3f..352fbcf1c2 100644 --- a/app/src/main/java/protect/card_locker/MainActivity.kt +++ b/app/src/main/java/protect/card_locker/MainActivity.kt @@ -531,38 +531,36 @@ class MainActivity : CatimaAppCompatActivity(), CardAdapterListener { } private fun importFile(data: Uri?, receivedType: String) { - lifecycleScope.launch(Dispatchers.IO) { - val parseResultList: MutableList = when { - receivedType.startsWith("image/") -> - Utils.retrieveBarcodesFromImage(this@MainActivity, data) - receivedType == "application/pdf" -> - Utils.retrieveBarcodesFromPdf(this@MainActivity, data) - mutableListOf( - "application/vnd.apple.pkpass", - "application/vnd-com.apple.pkpass" - ).contains(receivedType) -> - Utils.retrieveBarcodesFromPkPass(this@MainActivity, data) - // FIXME: espass is not pkpass - // However, several users stated in https://github.com/CatimaLoyalty/Android/issues/2197 that the formats are extremely similar to the point they could rename an .espass file to .pkpass and have it imported - // So it makes sense to "unofficially" treat it as a PKPASS for now, even though not completely correct - receivedType == "application/vnd.espass-espass" -> - Utils.retrieveBarcodesFromPkPass(this@MainActivity, data) - receivedType == "application/vnd.apple.pkpasses" -> - Utils.retrieveBarcodesFromPkPasses(this@MainActivity, data) - else -> { - Log.e(TAG, "Wrong mime-type") - return@launch - } - } - - withContext(Dispatchers.Main) { - if (parseResultList.isEmpty()) { - finish() - return@withContext + lifecycleScope.launch { + val parseResultList: MutableList = withContext(Dispatchers.IO) { + when { + receivedType.startsWith("image/") -> + Utils.retrieveBarcodesFromImage(this@MainActivity, data) + receivedType == "application/pdf" -> + Utils.retrieveBarcodesFromPdf(this@MainActivity, data) + receivedType == "application/vnd.apple.pkpass" || + receivedType == "application/vnd-com.apple.pkpass" -> + Utils.retrieveBarcodesFromPkPass(this@MainActivity, data) + // FIXME: espass is not pkpass + // However, several users stated in https://github.com/CatimaLoyalty/Android/issues/2197 that the formats are extremely similar to the point they could rename an .espass file to .pkpass and have it imported + // So it makes sense to "unofficially" treat it as a PKPASS for now, even though not completely correct + receivedType == "application/vnd.espass-espass" -> + Utils.retrieveBarcodesFromPkPass(this@MainActivity, data) + receivedType == "application/vnd.apple.pkpasses" -> + Utils.retrieveBarcodesFromPkPasses(this@MainActivity, data) + else -> { + Log.e(TAG, "Wrong mime-type") + return@withContext null + } } + } ?: return@launch - processParseResultList(parseResultList) + if (parseResultList.isEmpty()) { + finish() + return@launch } + + processParseResultList(parseResultList) } } From c1c3ea62725f524dccf72644e09df7e64ca056bd Mon Sep 17 00:00:00 2001 From: mvanhorn Date: Wed, 26 Aug 2026 05:45:52 -0700 Subject: [PATCH 4/4] Fix the two failing import tests Two separate bugs, both in the tests I added: - new Intent(ACTION_VIEW, uri).setType(...) clears the data URI (setType and setData clear each other), so the activity received a null Uri and the read never started. Use setDataAndType. - The intermediate-state assertions (no started activity / RESULT_CANCELED) are not observable: waiting on the main looper drains it, and Robolectric drains it again during .visible(), so the import has already run to completion by the time the test regains control. Assert the off-main-thread read directly instead, which is the property these tests exist to prove. --- .../protect/card_locker/MainActivityTest.java | 19 +++++++++++++------ .../protect/card_locker/ScanActivityTest.kt | 7 +++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/src/test/java/protect/card_locker/MainActivityTest.java b/app/src/test/java/protect/card_locker/MainActivityTest.java index 93b94effc2..620875a43e 100644 --- a/app/src/test/java/protect/card_locker/MainActivityTest.java +++ b/app/src/test/java/protect/card_locker/MainActivityTest.java @@ -69,16 +69,23 @@ public void pkpassSharedIntentReadsOffMainThreadAndProcessesOnMainThread() throw new ShadowContentResolver().registerInputStream( uri, new RecordingInputStream(pkpass, readStarted, streamClosed, readLooper)); - Intent intent = new Intent(Intent.ACTION_VIEW, uri) - .setType("application/vnd.apple.pkpass"); + Intent intent = new Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.apple.pkpass"); ActivityController controller = Robolectric.buildActivity(MainActivity.class, intent).create().start().resume().visible(); MainActivity activity = controller.get(); - waitFor(() -> readStarted.getCount() == 0); + // Do NOT idle the main looper here: the read runs on a real background + // thread, so the latch fires without it, and idling would run the + // main-thread continuation and defeat the ordering assertion below. + assertTrue(readStarted.await(5, TimeUnit.SECONDS)); + // The read must not happen on the main looper. This is the property the + // fix is about; it is asserted directly rather than inferred from + // ordering. Robolectric drains the main looper during .visible(), so by + // the time the test regains control the whole import has already run to + // completion and there is no observable in-between state to assert on. assertTrue(readLooper.get() != getMainLooper()); assertTrue(streamClosed.await(5, TimeUnit.SECONDS)); - assertNull(shadowOf(activity).peekNextStartedActivity()); waitFor(() -> shadowOf(activity).peekNextStartedActivity() != null); Intent editIntent = shadowOf(activity).getNextStartedActivity(); @@ -100,8 +107,8 @@ public void destroyingActivityCancelsPendingPkpassResult() throws Exception { new ShadowContentResolver().registerInputStream( uri, new BlockingInputStream(pkpass, readStarted, releaseRead, streamClosed)); - Intent intent = new Intent(Intent.ACTION_VIEW, uri) - .setType("application/vnd.apple.pkpass"); + Intent intent = new Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.apple.pkpass"); ActivityController controller = Robolectric.buildActivity(MainActivity.class, intent).create().start().resume(); MainActivity activity = controller.get(); diff --git a/app/src/test/java/protect/card_locker/ScanActivityTest.kt b/app/src/test/java/protect/card_locker/ScanActivityTest.kt index 4409ffcf0e..26667d5c24 100644 --- a/app/src/test/java/protect/card_locker/ScanActivityTest.kt +++ b/app/src/test/java/protect/card_locker/ScanActivityTest.kt @@ -52,10 +52,13 @@ class ScanActivityTest { handlePkpassResult(activity, Intent().setData(uri)) - waitFor { readStarted.count == 0L } + assertTrue(readStarted.await(5, TimeUnit.SECONDS)) + // The read must not happen on the main looper. Asserted directly rather + // than inferred from an intermediate result code: waiting on the main + // looper drains it, so the flow has already reached its final state by + // the time the test regains control. assertTrue(readLooper.get() !== Looper.getMainLooper()) assertTrue(streamClosed.await(5, TimeUnit.SECONDS)) - assertEquals(Activity.RESULT_CANCELED, shadowOf(activity).resultCode) waitFor { shadowOf(activity).resultCode == Activity.RESULT_OK } assertNotNull(shadowOf(activity).resultIntent)