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"