diff --git a/app/src/main/java/protect/card_locker/MainActivity.kt b/app/src/main/java/protect/card_locker/MainActivity.kt index 04692499a9..352fbcf1c2 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 @@ -502,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) { @@ -524,36 +526,42 @@ 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( - "application/vnd.apple.pkpass", - "application/vnd-com.apple.pkpass" - ).contains(receivedType) - ) { - parseResultList = Utils.retrieveBarcodesFromPkPass(this, data) - } 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) - } else if (receivedType == "application/vnd.apple.pkpasses") { - parseResultList = Utils.retrieveBarcodesFromPkPasses(this, data) - } else { - Log.e(TAG, "Wrong mime-type") - return - } + importFile(data, receivedType) } + } - // Give up if we should parse but there is nothing to parse - if (parseResultList == null || parseResultList.isEmpty()) { - finish() - return - } + private fun importFile(data: Uri?, receivedType: String) { + 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) + } } private fun extractIntentFields(intent: 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..fad13b96dc 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,15 +326,22 @@ class ScanActivity : CatimaAppCompatActivity() { private fun handleActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { super.onActivityResult(resultCode, resultCode, intent) - val parseResultList: List = - Utils.parseSetBarcodeActivityResult(requestCode, resultCode, intent, this) + lifecycleScope.launch(Dispatchers.IO) { + val parseResultList = + Utils.parseSetBarcodeActivityResult(requestCode, resultCode, intent, this@ScanActivity) - if (parseResultList.isEmpty()) { - setScannerActive(true) - return - } + withContext(Dispatchers.Main) { + if (parseResultList.isEmpty()) { + setScannerActive(true) + return@withContext + } + 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..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,7 +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"); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } @@ -190,7 +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); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } @@ -200,7 +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); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } } @@ -212,7 +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); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } parseResult.setNote(locale); @@ -226,7 +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"); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } @@ -235,7 +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); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } @@ -249,7 +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); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } parseResult.setNote(String.format(context.getString(R.string.cardWithNumber), i+1)); @@ -260,7 +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); - Toast.makeText(context, R.string.errorReadingFile, Toast.LENGTH_LONG).show(); + showToast(context, R.string.errorReadingFile); return new ArrayList<>(); } parseResult.setNote(String.format(context.getString(R.string.cardWithNumberAndLocale), i+1, locale)); @@ -278,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<>(); } @@ -315,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) { @@ -332,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; } @@ -365,19 +376,25 @@ 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(); + showToast(context, R.string.errorReadingFile); 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); + showToast(context, R.string.errorReadingFile); + 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..620875a43e 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,197 @@ 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) + .setDataAndType(uri, "application/vnd.apple.pkpass"); + ActivityController controller = + Robolectric.buildActivity(MainActivity.class, intent).create().start().resume().visible(); + MainActivity activity = controller.get(); + + // 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)); + + 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) + .setDataAndType(uri, "application/vnd.apple.pkpass"); + ActivityController controller = + Robolectric.buildActivity(MainActivity.class, intent).create().start().resume(); + MainActivity activity = controller.get(); + + waitFor(() -> readStarted.getCount() == 0); + 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..26667d5c24 --- /dev/null +++ b/app/src/test/java/protect/card_locker/ScanActivityTest.kt @@ -0,0 +1,225 @@ +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)) + // 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)) + + 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)) + waitFor { readStarted.count == 0L } + 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") + } +}