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"