From e4d75a5bfe78c1420e521bd7c1f439754be89c47 Mon Sep 17 00:00:00 2001 From: Bannon Tanner Date: Fri, 21 Aug 2026 12:11:18 -0500 Subject: [PATCH] FEATURE: Download support for iOS Previously, there was no way to download files in the Hub when on iOS. In React Native, downloads on iOS have to be manually handled and this commit adds support for that. --- ios/Discourse/Info.plist | 2 +- ios/Podfile.lock | 185 +++++++++++------- ios/ShareExtension/Info.plist | 2 +- js/lib/__tests__/handleDownload.test.js | 95 +++++++++ js/lib/handleDownload.js | 125 ++++++++++++ js/locale/en.json | 5 + .../WebViewComponent.js | 15 +- package.json | 1 + yarn.lock | 81 ++++++++ 9 files changed, 432 insertions(+), 79 deletions(-) create mode 100644 js/lib/__tests__/handleDownload.test.js create mode 100644 js/lib/handleDownload.js diff --git a/ios/Discourse/Info.plist b/ios/Discourse/Info.plist index 7b7a260c..f7e7bc85 100644 --- a/ios/Discourse/Info.plist +++ b/ios/Discourse/Info.plist @@ -37,7 +37,7 @@ CFBundleVersion - 700 + 705 Fabric APIKey diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 400121e1..bbb25f59 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1648,6 +1648,35 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket + - react-native-blob-util (0.24.10): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - react-native-blur (4.4.1): - boost - DoubleConversion @@ -1776,7 +1805,7 @@ PODS: - react-native-segmented-control (2.2.2): - React-Core - react-native-vector-icons-fontawesome5 (12.2.0) - - react-native-webview (13.17.0): + - react-native-webview (13.16.0): - boost - DoubleConversion - fast_float @@ -2722,6 +2751,7 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - react-native-blob-util (from `../node_modules/react-native-blob-util`) - "react-native-blur (from `../node_modules/@react-native-community/blur`)" - react-native-key-pair (from `../node_modules/react-native-key-pair`) - react-native-safari-view (from `../node_modules/react-native-safari-view`) @@ -2862,6 +2892,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" React-microtasksnativemodule: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-blob-util: + :path: "../node_modules/react-native-blob-util" react-native-blur: :path: "../node_modules/@react-native-community/blur" react-native-key-pair: @@ -2968,12 +3000,12 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 + DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: 86588b5a1547e7a417942a08f49559b184e002c8 - fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd + glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: bbc1152da7d2d40f9e59c28acc6576fcf5d28e2a RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 300c5eb91114d4339b0bb39505d0f4824d7299b7 @@ -2981,87 +3013,88 @@ SPEC CHECKSUMS: RCTTypeSafety: cb974efcdc6695deedf7bf1eb942f2a0603a063f React: e7a4655b09d0e17e54be188cc34c2f3e2087318a React-callinvoker: 62192daaa2f30c3321fc531e4f776f7b09cf892b - React-Core: b23cdaaa9d76389d958c06af3c57aa6ad611c542 - React-CoreModules: 8e0f562e5695991e455abbebe1e968af71d52553 - React-cxxreact: 6ccbe0cc2c652b29409b14b23cfb3cd74e084691 + React-Core: c400b068fdb6172177f3b3fae00c10d1077244d7 + React-CoreModules: 8e911a5a504b45824374eec240a78de7a6db8ca2 + React-cxxreact: 06a91f55ac5f842219d6ca47e0f77187a5b5f4ac React-debug: 1834225a63b420b16e9b8b01ba5870aee96d0610 - React-defaultsnativemodule: dd88d445d542d58ab61a8a29a7c1d2272dfed577 - React-domnativemodule: fc3c24f4d3bb92770727ea48b4133dab77ded7f7 - React-Fabric: 00fe76339e568da0d0497cc72daeeb01e463871a - React-FabricComponents: 7bb179ee55db68f88c007800b0ac62c930115a85 - React-FabricImage: 21e01118011dd1e4ff3cdab20dbf57839cff52ee - React-featureflags: 6e67f2e252bc8ebb1d538c2ae8c14df432fe5fc0 - React-featureflagsnativemodule: eff5216a5cde5df5d09243d15db1bc401474deef - React-graphics: 8539372da8754118a565251ed08a88fc70f69340 - React-hermes: cc8c77acee1406c258622cd8abbee9049f6b5761 - React-idlecallbacksnativemodule: 7349675d1ccbec876c29b0e206ac08c762baaa36 - React-ImageManager: 4089d8ad52c86a8ae1d7591282fff1665ff5518b - React-jserrorhandler: 89a7a5fa8d04791e729119d1db03bf0ee85a9e29 - React-jsi: ea5c640ea63c127080f158dac7f4f393d13d415c - React-jsiexecutor: cf7920f82e46fe9a484c15c9f31e67d7179aa826 - React-jsinspector: 69e974b6313dbbb635ba503f2f4f2c389b30edbf - React-jsinspectorcdp: 231ddd5b7164c37589dcde3b8b6960136c891d6d - React-jsinspectornetwork: ff74911f79cf0a407a7f0ad0eeb0be64687ed815 - React-jsinspectortracing: df2aa2d944bb3fa280d9c920b9a06664bca8a7e8 - React-jsitooling: 77849c27e374a028ed8106e434a35267f6c6600b - React-jsitracing: 0dc6978e5b38c6e5e01e6aed484e4aec3f5f581b - React-logger: 7cfc7b1ae1f8e5fe5097f9c746137cc3a8fad4ce - React-Mapbuffer: 7018c5b7da5b13ed22fe55dae51d50187a00b2d7 - React-microtasksnativemodule: 8ff9cb220a8efa625b5885996bd69e69db9edf02 - react-native-blur: 477fac85a42b5506232ef1d2b82aad359feadd26 - react-native-key-pair: f69098449fa3f63baac4f4280910d4acb5ec3d89 - react-native-safari-view: 07dc856a2663fef31eaca6beb79b111b8f6cf1f2 - react-native-safari-web-auth: 63e920416493c35c795bae6b70fbda283497705e - react-native-safe-area-context: 8ed800930eb7d0b6651218f69656e823d37b6ef7 - react-native-segmented-control: be570ddc1678933470a39471e0ebd824f23e39a8 + React-defaultsnativemodule: 260aa990a9617c58df46c00321f396ad6ea7cc7f + React-domnativemodule: 9b3456a614c325da986867f27ca0eb34cb86828c + React-Fabric: fc7bcbac28989e6025ca6ae0988bff61bb78e5d3 + React-FabricComponents: ae4a9c82bedf7c95bace1b215caf8685bcb32e23 + React-FabricImage: c9cd4786180c150bb2a3841d65d360fd52be9ef8 + React-featureflags: 534cd678e05848fbfc8c7288d4b14bcd8894b696 + React-featureflagsnativemodule: bf7419f4d81226a3c4dd792445a03a6d703ce9a4 + React-graphics: 18296c3559d54a42baaf7f2ae9c137a2e0fe9d51 + React-hermes: b6e33fcd21aa7523dc76e62acd7a547e68c28a5b + React-idlecallbacksnativemodule: da8696a714ab16adb56bbfc9e0dfb4de7a713340 + React-ImageManager: 052ccce122e4fd4e09c5d4f30e56381704dac439 + React-jserrorhandler: 4c037384a32f57332abfa64181aeea915f9e0f0d + React-jsi: 3fde19aaf675c0607a0824c4d6002a4943820fd9 + React-jsiexecutor: 4f898228240cf261a02568e985dfa7e1d7ad1dfb + React-jsinspector: 4ad0cdfa25a45d1362e2ddd06c78727d7964b34f + React-jsinspectorcdp: a649cc98a448e0fd8d54ac2a9e3e53177a1d8bd3 + React-jsinspectornetwork: 2d701b6b152be202342f8269223046ec664c7d47 + React-jsinspectortracing: cd898b3d7ea89f3e0ae10020fe3504bb4b327dd8 + React-jsitooling: feca163583c69ba642cebb6b8ccd2f5e6732fed8 + React-jsitracing: 1965307a468987b20d2a020f8fe782efa591ded7 + React-logger: ea80169d826e0cd112fa4d68f58b2b3b968f1ecb + React-Mapbuffer: a5d550d1add940ed2bc65b20dc1413407bf1a63f + React-microtasksnativemodule: 5d00fefc19f0bc9a6432e5533683d6fc9c3da4e1 + react-native-blob-util: 598a68ff8f2402eac074c54eac9a539238a5a222 + react-native-blur: 736888dd75460ec75605254214caded098066cfc + react-native-key-pair: 5492baed2d7bbff57341ece66ecf5f5f340da439 + react-native-safari-view: 955d7160d159241b8e9395d12d10ea0ef863dcdd + react-native-safari-web-auth: 73f4f00a0f1b6b1ad247e7e2b4ff7df5fe87a997 + react-native-safe-area-context: fd72bd1eb562ff2a0fda20e9a7828c4b0cabf5a4 + react-native-segmented-control: 65df6cd0619b780b3843d574a72d4c7cec396097 react-native-vector-icons-fontawesome5: 1199fa2cf3323fac0b9b52b2aecb1329aa3602a5 - react-native-webview: a685ccea981f1410f67245117d44bfb91c6d7df6 - React-NativeModulesApple: 37c08c3c54db55854de816b0df0f3683832be35a + react-native-webview: fe1b2ebf06ca1ba26e22caf7228abce5d9de4ec9 + React-NativeModulesApple: b22e6abb44d78270dfdfc7d85efe29e35e0333a7 React-oscompat: 56d6de59f9ae95cd006a1c40be2cde83bc06a4e1 - React-perflogger: 4008bd05a8b6c157b06608c0ea0b8bd5d9c5e6c9 - React-performancetimeline: 9321ba7605abcfb3a2b497fd7cbaf5cfd8c7cf67 + React-perflogger: 0633844e495d8b34798c9bf0cb32ce315f1d5c9f + React-performancetimeline: a04dae9154c32eda1891fcfa51cb2680a0421b3e React-RCTActionSheet: 49138012280ec3bbb35193d8d09adb8bc61c982e - React-RCTAnimation: ebfe7c62016d4c17b56b2cab3a221908ae46288d - React-RCTAppDelegate: 0108657ba9a19f6a1cd62dcd19c2c0485b3fc251 - React-RCTBlob: 6cc309d1623f3c2679125a04a7425685b7219e6b - React-RCTFabric: 0a9ff5c9d1e1d7fc026bda6671180cbf56861c15 - React-RCTFBReactNativeSpec: ff3e37e2456afc04211334e86d07bf20488df0ae - React-RCTImage: bb98a59aeed953a48be3f917b9b745b213b340ab - React-RCTLinking: d6e9795d4d75d154c1dd821fd0746cc3e05d6670 - React-RCTNetwork: 5c8a7a2dd26728323189362f149e788548ac72bc - React-RCTRuntime: 96808e8fdce300a26c82d8c24174e33ba5210a7c - React-RCTSettings: b6a02d545ce10dd936b39914b32674db6e865307 - React-RCTText: c7d9232da0e9b5082a99a617483d9164a9cd46e9 - React-RCTVibration: fe636c985c1bf25e4a5b5b4d9315a3b882468a72 + React-RCTAnimation: c7ed4a9d5a4e43c9b10f68bb43cd238c4a2e7e89 + React-RCTAppDelegate: ea2ab6f4aef1489f72025b7128d8ab645b40eafb + React-RCTBlob: c052799460b245e1fffe3d1dddea36fa88e998a0 + React-RCTFabric: e7acf005f8ed58d09f755b980ff83703b3af9fcf + React-RCTFBReactNativeSpec: ffb22c3ee3d359ae9245ca94af203845da9371ec + React-RCTImage: 59fc2571f4f109a77139924f5babee8f9cd639c9 + React-RCTLinking: a045cb58c08188dce6c6f4621de105114b1b16ce + React-RCTNetwork: fc7115a2f5e15ae0aa05e9a9be726817feefb482 + React-RCTRuntime: a7bca9be4f571586b2a9d4b57cf605421ffb6335 + React-RCTSettings: 30d7dd7eae66290467a1e72bf42d927fa78c3884 + React-RCTText: 755d59284e66c7d33bb4f0ccc428fe69110c3e74 + React-RCTVibration: ffe019e588815df226f6f8ccdc65979f8b2bc440 React-rendererconsistency: d20fcb77173861cc7d8356239823e3b36966fc31 - React-renderercss: 56461d1e18db6a325048fdd04a51d68bd7ddb5a8 - React-rendererdebug: fcd44d3eb8a02d74beee778bb142e724016c7375 + React-renderercss: 63c720c32aaabd4788ac4136a071d49a052d8002 + React-rendererdebug: a25ddddc73cabf50d814d8dfbc60d257b3d854c4 React-rncore: bafb76fc01b78757a9592e92dbc227f9260bf0ac - React-RuntimeApple: 01e3ad08793efaa54cf85276457fa4a1f103d5b4 - React-RuntimeCore: 5c4bec5bf402a99b134e55972f2f4e676c70b9ab + React-RuntimeApple: 45f8ef1b220a91b4fa4a79820b81990bffd95aa5 + React-RuntimeCore: a0e095493b22ee3f6c639df4258cc5185674f0b8 React-runtimeexecutor: b35de9cb7f5d19c66ea9b067235f95b947697ba5 - React-RuntimeHermes: ba549a5834a6592d243b9a605530ecd7b6f5e79c - React-runtimescheduler: 9a9914d58caec7976aaae381cd2d997408f2260f + React-RuntimeHermes: 5b8126fffd1531475861dc0294a10b5f9793271a + React-runtimescheduler: 44fa97351d105afd0ffaecc4ed11cadad562deb6 React-timing: 4f97958cc918f0af9444f93e4a7083415e6f5daf - React-utils: f491e2726eb8ced8af13893e1f77317f0fa9a954 - ReactAppDependencyProvider: 8df342c127fd0c1e30e8b9f71ff814c22414a7c0 - ReactCodegen: 439c427ccc115d71d16cc84256e5fbdc7fcef57a - ReactCommon: 592ef441605638b95e533653259254b4bd35ff4f - RNBackgroundFetch: e44c9e85d7fb3122c37d8a806278f62c7682d7ea - RNCAsyncStorage: 579cd39b67a6eac3ffc4ebb45ad169b11b0f6fe7 - RNCPushNotificationIOS: 6c4ca3388c7434e4a662b92e4dfeeee858e6f440 - RNDeviceInfo: ae26ae45db3f9937f038a284bcd0a1db8d70db96 - RNFlashList: e3c782f37970179e6b3c64f0f6007eb87d725f95 - RNGestureHandler: db29d3e7edf41a11d133cc3ffd4029762376ed1a - RNLocalize: 8bf466de4c92d4721b254aabe1ff0a1456e7b9f4 - RNReanimated: 5479b2116dcbbd2ba1eefb5561d5b322da306884 - RNScreens: 26bb60cdb2ef2ca06fd87feefc495072f25982a7 - RNSiriShortcuts: 149966f5de0cac63bd9fd8c2d5c51235439d1760 - RNSVG: d407e5f2ede12bbcd92e4d23a405ad5a5f7294a3 - RNWorklets: 83e051db94475b21fa729851b0d3aa523cd6f67a + React-utils: 3c4b0b7788e4dc132d1bf918bc0615e2b21f36b3 + ReactAppDependencyProvider: 6c9197c1f6643633012ab646d2bfedd1b0d25989 + ReactCodegen: 9ea66ee246511816b72e9d6e380f884b7b3b99d7 + ReactCommon: 7aca047f2f453a7d7f0adeccb63810d61829235a + RNBackgroundFetch: 534f5bba01d1e45559aa7d53f42e00124378db93 + RNCAsyncStorage: 8e9406ce79bba50a7ab965e1c05a93044f230de6 + RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8 + RNDeviceInfo: 29e01d5ae94bdb5a0f6c11a4c438132545b4df80 + RNFlashList: 995a7cad345dcb6f203db0f50bcf4b83cc04f3c9 + RNGestureHandler: cdca641e24d0ab743dcd90a24de4e6259e6aa0de + RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 + RNReanimated: 3d16d7db5b36d76df0477df0723d28d2235c97f8 + RNScreens: 45e1adbc3b4bab2900bf9bb9decfcfb59d0eed8c + RNSiriShortcuts: 17f5bcb1322eb9e9387101110db1cdb6ed82707c + RNSVG: 432ca012e24410cab11449afef353ce20573a59f + RNWorklets: a45d7a51e3c1f50e4d5cc73a1d11d34b76c843b4 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a742cc68e8366fcfc681808162492bc0aa7a9498 PODFILE CHECKSUM: 8125ac004090fc2c53beddf13d18c2becd0d99e9 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist index a01b4454..d69056d1 100644 --- a/ios/ShareExtension/Info.plist +++ b/ios/ShareExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 2.2.3 CFBundleVersion - 700 + 705 NSExtension NSExtensionAttributes diff --git a/js/lib/__tests__/handleDownload.test.js b/js/lib/__tests__/handleDownload.test.js new file mode 100644 index 00000000..8de31ec9 --- /dev/null +++ b/js/lib/__tests__/handleDownload.test.js @@ -0,0 +1,95 @@ +/* @flow */ +'use strict'; + +import { Alert, Platform, Share, ToastAndroid } from 'react-native'; +import ReactNativeBlobUtil from 'react-native-blob-util'; +import handleDownload, { + MAX_BRIDGED_DOWNLOAD_BYTES, + sanitizeFilename, + validateDownloadMessage, +} from '../handleDownload'; + +jest.mock('i18n-js', () => ({ + t: (key, params) => (params?.filename ? `${key}:${params.filename}` : key), +})); + +jest.mock('react-native-blob-util', () => ({ + fs: { + dirs: { CacheDir: '/cache' }, + writeFile: jest.fn(() => Promise.resolve()), + unlink: jest.fn(() => Promise.resolve()), + }, + MediaCollection: { + copyToMediaStore: jest.fn(() => Promise.resolve()), + }, +})); + +function message(overrides = {}) { + return { + type: 'download', + version: 1, + encoding: 'base64', + filename: 'theme.zip', + mimeType: 'application/zip', + byteLength: 1, + data: 'eA==', + ...overrides, + }; +} + +describe('handleDownload', () => { + beforeEach(() => { + jest.clearAllMocks(); + Platform.OS = 'ios'; + Share.share = jest.fn(() => Promise.resolve()); + Alert.alert = jest.fn(); + ToastAndroid.show = jest.fn(); + }); + + test('sanitizes filenames used in native paths', () => { + expect(sanitizeFilename('../../bad\\name:\u0000.zip')).toBe( + '_.._bad_name.zip', + ); + expect(sanitizeFilename('...')).toBe('download'); + }); + + test('rejects malformed and oversized messages', () => { + expect(() => + validateDownloadMessage(message({ data: 'not base64' })), + ).toThrow('Invalid download data'); + expect(() => + validateDownloadMessage( + message({ byteLength: MAX_BRIDGED_DOWNLOAD_BYTES + 1 }), + ), + ).toThrow('Download is too large'); + }); + + test('writes, shares, and removes an iOS temporary file', async () => { + await handleDownload(message()); + + expect(ReactNativeBlobUtil.fs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/^\/cache\/.+-theme\.zip$/), + 'eA==', + 'base64', + ); + expect(Share.share).toHaveBeenCalledWith( + expect.objectContaining({ title: 'theme.zip' }), + ); + expect(ReactNativeBlobUtil.fs.unlink).toHaveBeenCalled(); + expect(Alert.alert).not.toHaveBeenCalled(); + }); + + test('removes the temporary file and reports native failures', async () => { + ReactNativeBlobUtil.fs.writeFile.mockRejectedValueOnce( + new Error('disk full'), + ); + + await handleDownload(message()); + + expect(ReactNativeBlobUtil.fs.unlink).toHaveBeenCalled(); + expect(Alert.alert).toHaveBeenCalledWith( + 'download.failed_title', + 'disk full', + ); + }); +}); diff --git a/js/lib/handleDownload.js b/js/lib/handleDownload.js new file mode 100644 index 00000000..7cff55fa --- /dev/null +++ b/js/lib/handleDownload.js @@ -0,0 +1,125 @@ +/* @flow */ +'use strict'; + +import { Alert, Platform, Share, ToastAndroid } from 'react-native'; +import ReactNativeBlobUtil from 'react-native-blob-util'; +import i18n from 'i18n-js'; + +export const MAX_BRIDGED_DOWNLOAD_BYTES = 25 * 1024 * 1024; + +let downloadSequence = 0; + +export function sanitizeFilename(name) { + if (typeof name !== 'string') { + return 'download'; + } + + const cleaned = name + .normalize('NFC') + .replace(/[\p{Cc}\u202a-\u202e\u2066-\u2069]/gu, '') + .replace(/^\.+/, '') + .replace(/[. ]+$/, '') + .replace(/[/\\:]+/g, '_') + .replace(/_+(\.[^.]*)$/, '$1') + .trim() + .slice(0, 180); + + return cleaned || 'download'; +} + +function base64ByteLength(value) { + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + return Math.floor((value.length * 3) / 4) - padding; +} + +export function validateDownloadMessage(message) { + if ( + !message || + typeof message !== 'object' || + message.type !== 'download' || + message.version !== 1 || + message.encoding !== 'base64' || + typeof message.filename !== 'string' || + typeof message.mimeType !== 'string' || + typeof message.data !== 'string' || + !Number.isSafeInteger(message.byteLength) + ) { + throw new Error('Invalid download request'); + } + + if ( + message.byteLength < 0 || + message.byteLength > MAX_BRIDGED_DOWNLOAD_BYTES || + message.data.length > Math.ceil((MAX_BRIDGED_DOWNLOAD_BYTES * 4) / 3) + 2 + ) { + throw new Error('Download is too large'); + } + + if ( + !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(message.mimeType) || + !/^[a-z0-9+/]*={0,2}$/i.test(message.data) || + message.data.length % 4 !== 0 || + base64ByteLength(message.data) !== message.byteLength + ) { + throw new Error('Invalid download data'); + } + + return { + filename: sanitizeFilename(message.filename), + mimeType: message.mimeType, + data: message.data, + }; +} + +function temporaryPath(filename) { + downloadSequence += 1; + const uniquePart = `${Date.now()}-${downloadSequence}-${Math.random() + .toString(36) + .slice(2, 10)}`; + return `${ReactNativeBlobUtil.fs.dirs.CacheDir}/${uniquePart}-${filename}`; +} + +async function saveAndroidDownload(path, { filename, mimeType }) { + if (ReactNativeBlobUtil.MediaCollection?.copyToMediaStore) { + await ReactNativeBlobUtil.MediaCollection.copyToMediaStore( + { name: filename, parentFolder: '', mimeType }, + 'Download', + path, + ); + ToastAndroid.show( + i18n.t('download.saved', { filename }), + ToastAndroid.LONG, + ); + return; + } + + throw new Error(i18n.t('download.unsupported')); +} + +export default async function handleDownload(message) { + let path; + + try { + const download = validateDownloadMessage(message); + path = temporaryPath(download.filename); + await ReactNativeBlobUtil.fs.writeFile(path, download.data, 'base64'); + + if (Platform.OS === 'ios') { + await Share.share({ + url: `file://${path}`, + title: download.filename, + }); + } else { + await saveAndroidDownload(path, download); + } + } catch (error) { + Alert.alert( + i18n.t('download.failed_title'), + error instanceof Error ? error.message : String(error), + ); + } finally { + if (path) { + await ReactNativeBlobUtil.fs.unlink(path).catch(() => {}); + } + } +} diff --git a/js/locale/en.json b/js/locale/en.json index e7e92390..f34d6f0c 100644 --- a/js/locale/en.json +++ b/js/locale/en.json @@ -1,4 +1,9 @@ { + "download": { + "failed_title": "Download failed", + "saved": "Saved %{filename} to Downloads", + "unsupported": "Saving downloads is not supported on this device" + }, "add_site": "Add Site", "add_sites": "Get started and follow the Discourse communities you love!", "no_sites_yet": "Welcome to Discourse Hub!", diff --git a/js/screens/WebViewScreenComponents/WebViewComponent.js b/js/screens/WebViewScreenComponents/WebViewComponent.js index e957fe57..2775f280 100644 --- a/js/screens/WebViewScreenComponents/WebViewComponent.js +++ b/js/screens/WebViewScreenComponents/WebViewComponent.js @@ -17,6 +17,7 @@ import { import { WebView } from 'react-native-webview'; import ErrorScreen from '../WebViewScreenComponents/ErrorScreen'; import ProgressBar from '../../ProgressBar'; +import handleDownload from '../../lib/handleDownload'; import chroma from 'chroma-js'; import SafariView from 'react-native-safari-view'; import i18n from 'i18n-js'; @@ -452,7 +453,19 @@ class WebViewComponent extends React.Component { } _onMessage(event) { - let data = JSON.parse(event.nativeEvent.data); + let data; + + try { + data = JSON.parse(event.nativeEvent.data); + } catch (error) { + console.warn('WebView message: unparseable JSON', error); + return; + } + + if (data?.type === 'download') { + void handleDownload(data); + return; + } let { headerBg, shareUrl, dismiss, markRead, showLogin } = data; diff --git a/package.json b/package.json index 24fcefa8..a65da6d7 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "react": "19.1.0", "react-native": "^0.80.2", "react-native-background-fetch": "^4.2.3", + "react-native-blob-util": "^0.24.10", "react-native-custom-tabs": "https://github.com/adminphoeniixx/react-native-custom-tabs", "react-native-device-info": "^13.1.0", "react-native-draggable-flatlist": "^4.0.3", diff --git a/yarn.lock b/yarn.lock index 75ccc1a1..14f249a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4954,6 +4954,20 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0 + languageName: node + linkType: hard + +"base-64@npm:1.0.0": + version: 1.0.0 + resolution: "base-64@npm:1.0.0" + checksum: 10/d10b64a1fc9b2c5a5f39f1ce1e6c9d1c5b249222bbfa3a0604c592d90623caf74419983feadd8a170f27dc0c3389704f72faafa3e645aeb56bfc030c93ff074a + languageName: node + linkType: hard + "base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -5059,6 +5073,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.8": + version: 5.0.9 + resolution: "brace-expansion@npm:5.0.9" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10/d8683d612919212c7cf298861acd1c15101e7e2ad186e7ae9747390e5b3fc9e9b55ce71107570d32985784d9d88fbbe438d725863aed7b0f3d08d5f080535eec + languageName: node + linkType: hard + "braces@npm:^3.0.3": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -6237,6 +6260,7 @@ __metadata: react: "npm:19.1.0" react-native: "npm:^0.80.2" react-native-background-fetch: "npm:^4.2.3" + react-native-blob-util: "npm:^0.24.10" react-native-custom-tabs: "https://github.com/adminphoeniixx/react-native-custom-tabs" react-native-device-info: "npm:^13.1.0" react-native-draggable-flatlist: "npm:^4.0.3" @@ -7643,6 +7667,17 @@ __metadata: languageName: node linkType: hard +"glob@npm:13.0.6": + version: 13.0.6 + resolution: "glob@npm:13.0.6" + dependencies: + minimatch: "npm:^10.2.2" + minipass: "npm:^7.1.3" + path-scurry: "npm:^2.0.2" + checksum: 10/201ad69e5f0aa74e1d8c00a481581f8b8c804b6a4fbfabeeb8541f5d756932800331daeba99b58fb9e4cd67e12ba5a7eba5b82fb476691588418060b84353214 + languageName: node + linkType: hard + "glob@npm:^10.2.2, glob@npm:^10.4.5": version: 10.4.5 resolution: "glob@npm:10.4.5" @@ -9886,6 +9921,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.0.0": + version: 11.5.2 + resolution: "lru-cache@npm:11.5.2" + checksum: 10/122789c66605ddf29df58ff279e9e2c9499499d0b80b895ec524496f14bcde045d1582e3e38008f3457226d98067556719573b352119d6be8bdb1f8849f85681 + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -10331,6 +10373,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.2": + version: 10.2.6 + resolution: "minimatch@npm:10.2.6" + dependencies: + brace-expansion: "npm:^5.0.8" + checksum: 10/a5e43f7c57d6061a77da320b42aa35ffff826030cf67c83c0eee47a26ede4d66486935881492b968c609bf27a7d48cc4b9f54b2806643cd8fb792fcf9240cc1b + languageName: node + linkType: hard + "minimatch@npm:^5.0.1": version: 5.1.6 resolution: "minimatch@npm:5.1.6" @@ -10430,6 +10481,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^7.1.3": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10/175e4d5e20980c3cd316ae82d2c031c42f6c746467d8b1905b51060a0ba4461441a0c25bb67c025fd9617f9a3873e152c7b543c6b5ac83a1846be8ade80dffd6 + languageName: node + linkType: hard + "minizlib@npm:^2.1.1": version: 2.1.2 resolution: "minizlib@npm:2.1.2" @@ -11495,6 +11553,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^2.0.2": + version: 2.0.2 + resolution: "path-scurry@npm:2.0.2" + dependencies: + lru-cache: "npm:^11.0.0" + minipass: "npm:^7.1.2" + checksum: 10/2b4257422bcb870a4c2d205b3acdbb213a72f5e2250f61c80f79c9d014d010f82bdf8584441612c8e1fa4eb098678f5704a66fa8377d72646bad4be38e57a2c3 + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -11922,6 +11990,19 @@ __metadata: languageName: node linkType: hard +"react-native-blob-util@npm:^0.24.10": + version: 0.24.10 + resolution: "react-native-blob-util@npm:0.24.10" + dependencies: + base-64: "npm:1.0.0" + glob: "npm:13.0.6" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/3999fbe4c856fd45f09248a80c324c5cd464d9774c705138e864e06d8dab826d2be1ccba25beea09ce1352b66922094eef35435612d5b5e9bde5443803f0269a + languageName: node + linkType: hard + "react-native-custom-tabs@https://github.com/adminphoeniixx/react-native-custom-tabs": version: 0.1.8 resolution: "react-native-custom-tabs@https://github.com/adminphoeniixx/react-native-custom-tabs.git#commit=7605cda9d9d691c36367e98d8513937454627ecd"