From 06beaa83a5339dd605b0896d68641b6b3fdb92e4 Mon Sep 17 00:00:00 2001 From: Simon Duchastel <163092709+simond-stripe@users.noreply.github.com> Date: Fri, 15 Nov 2024 11:03:16 -0800 Subject: [PATCH] [Connect SDK] Enable downloads in the Connect SDK (#9583) * Initial implementation * minor refactors * Fix strict mode violations * implement basic download finished toast * Remove file opening code (not working), make internal * Add test * Fix test * Fix lint (unused import) * Close resource * Refactor into interfaces * Make interfaces internal * Move scope into toast manager * Better abstractions * Fix test --- connect/build.gradle | 1 - connect/res/values/strings.xml | 3 + .../webview/StripeConnectWebViewClient.kt | 3 + .../connect/webview/StripeDownloadListener.kt | 68 +++++++++++++ .../connect/webview/StripeDownloadManager.kt | 69 ++++++++++++++ .../connect/webview/StripeToastManager.kt | 25 +++++ .../webview/StripeConnectWebViewClientTest.kt | 1 + .../webview/StripeDownloadListenerTest.kt | 95 +++++++++++++++++++ 8 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 connect/src/main/java/com/stripe/android/connect/webview/StripeDownloadListener.kt create mode 100644 connect/src/main/java/com/stripe/android/connect/webview/StripeDownloadManager.kt create mode 100644 connect/src/main/java/com/stripe/android/connect/webview/StripeToastManager.kt create mode 100644 connect/src/test/java/com/stripe/android/connect/webview/StripeDownloadListenerTest.kt diff --git a/connect/build.gradle b/connect/build.gradle index d9934aee3c8..195bf797796 100644 --- a/connect/build.gradle +++ b/connect/build.gradle @@ -54,7 +54,6 @@ dependencies { testImplementation testLibs.mockito.kotlin testImplementation testLibs.robolectric testImplementation testLibs.truth - testImplementation project(':stripe-core') androidTestImplementation testLibs.androidx.composeUi androidTestImplementation testLibs.androidx.coreKtx diff --git a/connect/res/values/strings.xml b/connect/res/values/strings.xml index 709af97caf0..0a2b0fd287f 100644 --- a/connect/res/values/strings.xml +++ b/connect/res/values/strings.xml @@ -1,4 +1,7 @@ Not yet built… + Downloading file + Download complete + Unable to download file diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewClient.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewClient.kt index fe65b51f00a..e2cbd4372a1 100644 --- a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewClient.kt +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewClient.kt @@ -54,6 +54,9 @@ internal class StripeConnectWebViewClient( useWideViewPort = true userAgentString = "$userAgentString - stripe-android/${StripeSdkVersion.VERSION_NAME}" } + + setDownloadListener(StripeDownloadListener(webView.context)) + addJavascriptInterface(StripeJsInterface(), ANDROID_JS_INTERFACE) addJavascriptInterface(StripeJsInterfaceInternal(this), ANDROID_JS_INTERNAL_INTERFACE) diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeDownloadListener.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeDownloadListener.kt new file mode 100644 index 00000000000..0ec0a03f041 --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeDownloadListener.kt @@ -0,0 +1,68 @@ +package com.stripe.android.connect.webview + +import android.app.DownloadManager.STATUS_PAUSED +import android.app.DownloadManager.STATUS_PENDING +import android.app.DownloadManager.STATUS_RUNNING +import android.content.Context +import android.webkit.DownloadListener +import com.stripe.android.connect.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal class StripeDownloadListener( + private val context: Context, + private val stripeDownloadManager: StripeDownloadManager = StripeDownloadManagerImpl(context), + private val stripeToastManager: StripeToastManager = StripeToastManagerImpl(context), + private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO), +) : DownloadListener { + + override fun onDownloadStart( + url: String?, + userAgent: String?, + contentDisposition: String?, + mimetype: String?, + contentLength: Long + ) { + if (url == null) { + showErrorToast() + return + } + + ioScope.launch { + val downloadId = stripeDownloadManager.enqueueDownload(url, contentDisposition, mimetype) + if (downloadId == null) { + showErrorToast() + return@launch + } + + // Monitor the download progress and show a toast when done + val query = stripeDownloadManager.getQueryById(downloadId) + var isDownloading = true + while (isDownloading) { + val status = stripeDownloadManager.getDownloadStatus(query) + if (status == null) { + showErrorToast() + return@launch + } else if (status !in listOf(STATUS_PENDING, STATUS_RUNNING, STATUS_PAUSED)) { + showOpenFileToast() + isDownloading = false // download complete - exit the loop + } + delay(DOWNLOAD_DELAY_MS) + } + } + } + + private fun showErrorToast() { + stripeToastManager.showToast(context.getString(R.string.stripe_unable_to_download_file)) + } + + private fun showOpenFileToast() { + stripeToastManager.showToast(context.getString(R.string.stripe_download_complete)) + } + + internal companion object { + private const val DOWNLOAD_DELAY_MS = 1_000L + } +} diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeDownloadManager.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeDownloadManager.kt new file mode 100644 index 00000000000..02ea08eb77f --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeDownloadManager.kt @@ -0,0 +1,69 @@ +package com.stripe.android.connect.webview + +import android.app.DownloadManager +import android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED +import android.content.Context +import android.net.Uri +import android.os.Environment.DIRECTORY_DOWNLOADS +import android.webkit.URLUtil +import com.stripe.android.connect.R + +/** + * Provides an interface for various download and file operations. Useful for mocking in tests. + */ +internal interface StripeDownloadManager { + + /** + * Enqueues a download for the given URL, content disposition, and MIME type. + * Returns null if a download could not be started. + */ + fun enqueueDownload(url: String, contentDisposition: String? = null, mimeType: String? = null): Long? + + /** + * Returns a [DownloadManager.Query] for the given download ID, used for monitoring the status of a download. + */ + fun getQueryById(id: Long): DownloadManager.Query + + /** + * Returns the status of the download represented by [query]. Maps to [DownloadManager.COLUMN_STATUS], ie. + * [DownloadManager.STATUS_PENDING], [DownloadManager.STATUS_RUNNING], [DownloadManager.STATUS_PAUSED], etc. + * + * Returns null if the status could not be determined. This operation should not be retried if null is returned. + */ + fun getDownloadStatus(query: DownloadManager.Query): Int? +} + +internal class StripeDownloadManagerImpl(private val context: Context) : StripeDownloadManager { + private val downloadManager: DownloadManager? = + context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager + + override fun enqueueDownload(url: String, contentDisposition: String?, mimeType: String?): Long? { + downloadManager ?: return null + + val request = DownloadManager.Request(Uri.parse(url)) + val fileName = URLUtil.guessFileName(url, contentDisposition, mimeType) + request.setDestinationInExternalPublicDir(DIRECTORY_DOWNLOADS, fileName) + request.setNotificationVisibility(VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + request.setTitle(fileName) + request.setDescription(context.getString(R.string.stripe_downloading_file)) + request.setMimeType(mimeType) + + return downloadManager.enqueue(request) + } + + override fun getQueryById(id: Long): DownloadManager.Query { + return DownloadManager.Query().setFilterById(id) + } + + override fun getDownloadStatus(query: DownloadManager.Query): Int? { + downloadManager ?: return null + + val cursor = downloadManager.query(query) ?: return null + return cursor.use { resource -> + resource.moveToFirst() + val index = resource.getColumnIndex(DownloadManager.COLUMN_STATUS) + if (index < 0) return null // status does not exist - abort + resource.getInt(index) + } + } +} diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeToastManager.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeToastManager.kt new file mode 100644 index 00000000000..e690729b867 --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeToastManager.kt @@ -0,0 +1,25 @@ +package com.stripe.android.connect.webview + +import android.content.Context +import android.widget.Toast +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch + +/** + * Provides an interface for various download and file operations. Useful for mocking in tests. + */ +internal interface StripeToastManager { + fun showToast(toastString: String) +} + +internal class StripeToastManagerImpl( + private val context: Context, + private val scope: CoroutineScope = MainScope() +) : StripeToastManager { + override fun showToast(toastString: String) { + scope.launch { + Toast.makeText(context, toastString, Toast.LENGTH_LONG).show() + } + } +} diff --git a/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewClientTest.kt b/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewClientTest.kt index 00c99c48c23..ba96c6207f5 100644 --- a/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewClientTest.kt +++ b/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewClientTest.kt @@ -25,6 +25,7 @@ class StripeConnectWebViewClientTest { } private val mockWebView: WebView = mock { on { settings } doReturn mockSettings + on { context } doReturn mock() } private lateinit var webViewClient: StripeConnectWebViewClient diff --git a/connect/src/test/java/com/stripe/android/connect/webview/StripeDownloadListenerTest.kt b/connect/src/test/java/com/stripe/android/connect/webview/StripeDownloadListenerTest.kt new file mode 100644 index 00000000000..c88572550a2 --- /dev/null +++ b/connect/src/test/java/com/stripe/android/connect/webview/StripeDownloadListenerTest.kt @@ -0,0 +1,95 @@ +package com.stripe.android.connect.webview + +import android.app.DownloadManager +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class StripeDownloadListenerTest { + + private val context: Context = mock { + on { getString(any()) } doReturn "Placeholder string" + } + private val stripeDownloadManager: StripeDownloadManager = mock { + on { enqueueDownload(any(), anyOrNull(), anyOrNull()) } doReturn 123L + on { getQueryById(any()) } doReturn mock() + on { getDownloadStatus(any()) } doReturn DownloadManager.STATUS_SUCCESSFUL + } + private val stripeToastManager: StripeToastManager = mock() + private val testScope = TestScope() + + private lateinit var stripeDownloadListener: StripeDownloadListener + + @Before + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + initDownloadListener() + } + + private fun initDownloadListener( + context: Context = this.context, + stripeDownloadManager: StripeDownloadManager = this.stripeDownloadManager, + stripeToastManager: StripeToastManager = this.stripeToastManager, + ioScope: CoroutineScope = testScope, + ) { + stripeDownloadListener = StripeDownloadListener( + context = context, + stripeDownloadManager = stripeDownloadManager, + stripeToastManager = stripeToastManager, + ioScope = ioScope, + ) + } + + @Test + fun `onDownloadStart creates download request`() = runTest { + val url = "https://example.com/file.pdf" + val userAgent = "Mozilla/5.0" + val contentDisposition = "attachment; filename=file.pdf" + val mimeType = "application/pdf" + val contentLength = 1024L + + stripeDownloadListener.onDownloadStart(url, userAgent, contentDisposition, mimeType, contentLength) + testScope.testScheduler.advanceUntilIdle() + + verify(stripeDownloadManager).enqueueDownload(url, contentDisposition, mimeType) + verify(stripeToastManager).showToast(any()) + } + + @Test + fun `onDownloadStart does nothing when URL is null`() = runTest { + stripeDownloadListener.onDownloadStart(null, "", "", "", 0) + testScope.testScheduler.advanceUntilIdle() + + verifyNoInteractions(stripeDownloadManager) + verify(stripeToastManager).showToast(any()) + } + + @Test + fun `onDownloadStart shows error toast when enqueue returns null`() = runTest { + whenever(stripeDownloadManager.enqueueDownload(any(), anyOrNull(), anyOrNull())).thenReturn(null) + + val url = "https://example.com/file.pdf" + val userAgent = "Mozilla/5.0" + val contentDisposition = "attachment; filename=file.pdf" + val mimeType = "application/pdf" + val contentLength = 1024L + + stripeDownloadListener.onDownloadStart(url, userAgent, contentDisposition, mimeType, contentLength) + testScope.testScheduler.advanceUntilIdle() + + verify(stripeToastManager).showToast(any()) + } +}