@appsonair.ir/react-native 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,6 +54,7 @@ android {
54
54
  sourceSets {
55
55
  main {
56
56
  java.srcDirs += ["src/main/java"]
57
+ assets.srcDirs += [file("${projectDir}/../firebase")]
57
58
  if (isReactNative76OrNewer()) {
58
59
  java.srcDirs += ["src/reacthost76/java"]
59
60
  }
@@ -65,4 +66,5 @@ dependencies {
65
66
  implementation "com.facebook.react:react-android"
66
67
  implementation "org.jetbrains.kotlin:kotlin-stdlib"
67
68
  implementation "androidx.core:core:1.13.1"
69
+ implementation "com.google.firebase:firebase-messaging:24.1.1"
68
70
  }
@@ -1,5 +1,6 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
2
  <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
3
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
3
4
 
4
5
  <application>
5
6
  <activity
@@ -8,6 +9,12 @@
8
9
  android:process=":ota_restart"
9
10
  android:theme="@android:style/Theme.Translucent.NoTitleBar" />
10
11
 
12
+ <provider
13
+ android:name=".OTAFirebaseInitProvider"
14
+ android:authorities="${applicationId}.otaupdater.firebaseinit"
15
+ android:exported="false"
16
+ android:initOrder="99" />
17
+
11
18
  <provider
12
19
  android:name="androidx.core.content.FileProvider"
13
20
  android:authorities="${applicationId}.otaupdater.fileprovider"
@@ -17,5 +24,9 @@
17
24
  android:name="android.support.FILE_PROVIDER_PATHS"
18
25
  android:resource="@xml/ota_file_paths" />
19
26
  </provider>
27
+
28
+ <meta-data
29
+ android:name="com.google.firebase.messaging.default_notification_channel_id"
30
+ android:value="ota_updates" />
20
31
  </application>
21
32
  </manifest>
@@ -0,0 +1,174 @@
1
+ package com.otaupdater
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.app.NotificationChannel
6
+ import android.app.NotificationManager
7
+ import android.content.Context
8
+ import android.content.pm.PackageManager
9
+ import android.os.Build
10
+ import androidx.core.app.ActivityCompat
11
+ import androidx.core.content.ContextCompat
12
+ import com.google.firebase.FirebaseApp
13
+ import com.google.firebase.FirebaseOptions
14
+ import com.google.firebase.messaging.FirebaseMessaging
15
+ import org.json.JSONObject
16
+
17
+ /**
18
+ * Shared Firebase project shipped inside this library. Host apps do not need
19
+ * their own google-services.json — drop yours into
20
+ * `packages/react-native/firebase/google-services.json` before publishing.
21
+ */
22
+ internal object OTAFcmHelper {
23
+ const val APP_NAME = "ota-updater"
24
+ const val CHANNEL_ID = "ota_updates"
25
+ private const val CONFIG_ASSET = "google-services.json"
26
+
27
+ fun warmup(context: Context) {
28
+ ensureFirebaseApp(context.applicationContext)
29
+ ensureNotificationChannel(context.applicationContext)
30
+ }
31
+
32
+ fun requestNotificationPermission(activity: Activity?) {
33
+ if (activity == null || Build.VERSION.SDK_INT < 33) {
34
+ return
35
+ }
36
+
37
+ try {
38
+ val permission = Manifest.permission.POST_NOTIFICATIONS
39
+ if (ContextCompat.checkSelfPermission(activity, permission) !=
40
+ PackageManager.PERMISSION_GRANTED
41
+ ) {
42
+ ActivityCompat.requestPermissions(activity, arrayOf(permission), 2401)
43
+ }
44
+ } catch (_: Throwable) {
45
+ // Host app may omit the permission; token fetch still proceeds.
46
+ }
47
+ }
48
+
49
+ fun getToken(context: Context, callback: (String?) -> Unit) {
50
+ val app = ensureFirebaseApp(context.applicationContext)
51
+ if (app == null) {
52
+ callback(null)
53
+ return
54
+ }
55
+
56
+ ensureNotificationChannel(context.applicationContext)
57
+
58
+ try {
59
+ FirebaseMessaging.getInstance(app).token.addOnCompleteListener { task ->
60
+ val token =
61
+ if (task.isSuccessful) {
62
+ task.result?.takeIf { it.isNotBlank() }
63
+ } else {
64
+ null
65
+ }
66
+ callback(token)
67
+ }
68
+ } catch (_: Throwable) {
69
+ callback(null)
70
+ }
71
+ }
72
+
73
+ fun ensureFirebaseApp(context: Context): FirebaseApp? {
74
+ try {
75
+ return FirebaseApp.getInstance(APP_NAME)
76
+ } catch (_: IllegalStateException) {
77
+ // Not created yet.
78
+ }
79
+
80
+ val options = loadOptions(context) ?: return null
81
+ return try {
82
+ FirebaseApp.initializeApp(context, options, APP_NAME)
83
+ } catch (_: IllegalStateException) {
84
+ try {
85
+ FirebaseApp.getInstance(APP_NAME)
86
+ } catch (_: Throwable) {
87
+ null
88
+ }
89
+ } catch (_: Throwable) {
90
+ null
91
+ }
92
+ }
93
+
94
+ private fun loadOptions(context: Context): FirebaseOptions? {
95
+ return try {
96
+ val json =
97
+ context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
98
+ parseGoogleServices(json, context.packageName)
99
+ } catch (_: Throwable) {
100
+ null
101
+ }
102
+ }
103
+
104
+ private fun parseGoogleServices(raw: String, packageName: String): FirebaseOptions? {
105
+ val root = JSONObject(raw)
106
+ val info = root.optJSONObject("project_info") ?: return null
107
+ val clients = root.optJSONArray("client") ?: return null
108
+ if (clients.length() == 0) {
109
+ return null
110
+ }
111
+
112
+ var client = clients.getJSONObject(0)
113
+ for (i in 0 until clients.length()) {
114
+ val candidate = clients.getJSONObject(i)
115
+ val pkg =
116
+ candidate
117
+ .optJSONObject("client_info")
118
+ ?.optJSONObject("android_client_info")
119
+ ?.optString("package_name")
120
+ if (pkg == packageName) {
121
+ client = candidate
122
+ break
123
+ }
124
+ }
125
+
126
+ val appId =
127
+ client.optJSONObject("client_info")?.optString("mobilesdk_app_id").orEmpty()
128
+ val apiKey =
129
+ client.optJSONArray("api_key")?.optJSONObject(0)?.optString("current_key").orEmpty()
130
+ val projectId = info.optString("project_id")
131
+ val senderId = info.optString("project_number")
132
+ val bucket = info.optString("storage_bucket")
133
+
134
+ if (!isUsable(appId) || !isUsable(apiKey) || !isUsable(projectId) || !isUsable(senderId)) {
135
+ return null
136
+ }
137
+
138
+ val builder =
139
+ FirebaseOptions.Builder()
140
+ .setApplicationId(appId)
141
+ .setApiKey(apiKey)
142
+ .setProjectId(projectId)
143
+ .setGcmSenderId(senderId)
144
+
145
+ if (bucket.isNotBlank()) {
146
+ builder.setStorageBucket(bucket)
147
+ }
148
+
149
+ return builder.build()
150
+ }
151
+
152
+ private fun isUsable(value: String?): Boolean {
153
+ val text = value?.trim().orEmpty()
154
+ return text.isNotEmpty() && text != "REPLACE_ME"
155
+ }
156
+
157
+ private fun ensureNotificationChannel(context: Context) {
158
+ if (Build.VERSION.SDK_INT < 26) {
159
+ return
160
+ }
161
+
162
+ try {
163
+ val manager = context.getSystemService(NotificationManager::class.java) ?: return
164
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) {
165
+ return
166
+ }
167
+ manager.createNotificationChannel(
168
+ NotificationChannel(CHANNEL_ID, "Updates", NotificationManager.IMPORTANCE_DEFAULT),
169
+ )
170
+ } catch (_: Throwable) {
171
+ // Channel is only needed to display notification payloads.
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,36 @@
1
+ package com.otaupdater
2
+
3
+ import android.content.ContentProvider
4
+ import android.content.ContentValues
5
+ import android.database.Cursor
6
+ import android.net.Uri
7
+
8
+ /** Initializes the library Firebase app before Application.onCreate. */
9
+ class OTAFirebaseInitProvider : ContentProvider() {
10
+ override fun onCreate(): Boolean {
11
+ val ctx = context ?: return false
12
+ OTAFcmHelper.warmup(ctx)
13
+ return true
14
+ }
15
+
16
+ override fun query(
17
+ uri: Uri,
18
+ projection: Array<out String>?,
19
+ selection: String?,
20
+ selectionArgs: Array<out String>?,
21
+ sortOrder: String?,
22
+ ): Cursor? = null
23
+
24
+ override fun getType(uri: Uri): String? = null
25
+
26
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
27
+
28
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
29
+
30
+ override fun update(
31
+ uri: Uri,
32
+ values: ContentValues?,
33
+ selection: String?,
34
+ selectionArgs: Array<out String>?,
35
+ ): Int = 0
36
+ }
@@ -98,6 +98,14 @@ class OTAUpdaterModule(private val reactContext: ReactApplicationContext) :
98
98
  }
99
99
  }
100
100
 
101
+ @ReactMethod
102
+ fun getFcmToken(promise: Promise) {
103
+ OTAFcmHelper.requestNotificationPermission(reactContext.currentActivity)
104
+ OTAFcmHelper.getToken(reactContext) { token ->
105
+ promise.resolve(token)
106
+ }
107
+ }
108
+
101
109
  @ReactMethod
102
110
  fun installApk(path: String, promise: Promise) {
103
111
  UiThreadUtil.runOnUiThread {
@@ -0,0 +1,27 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "appsonair-react-native"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = "https://appsonair.ir"
10
+ s.license = "MIT"
11
+ s.authors = "appsonair"
12
+ s.platforms = { :ios => "13.0" }
13
+ s.source = { :git => "https://github.com/appsonair/ota-updater.git", :tag => "#{s.version}" }
14
+
15
+ s.source_files = "ios/**/*.{h,m,mm}"
16
+ s.resource_bundles = {
17
+ "OTAFirebaseConfig" => ["firebase/GoogleService-Info.plist"]
18
+ }
19
+
20
+ s.dependency "FirebaseMessaging"
21
+
22
+ if respond_to?(:install_modules_dependencies, true)
23
+ install_modules_dependencies(s)
24
+ else
25
+ s.dependency "React-Core"
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>API_KEY</key>
6
+ <string>REPLACE_ME</string>
7
+ <key>GCM_SENDER_ID</key>
8
+ <string>REPLACE_ME</string>
9
+ <key>GOOGLE_APP_ID</key>
10
+ <string>REPLACE_ME</string>
11
+ <key>PROJECT_ID</key>
12
+ <string>REPLACE_ME</string>
13
+ <key>STORAGE_BUCKET</key>
14
+ <string></string>
15
+ <key>BUNDLE_ID</key>
16
+ <string>ir.appsonair.ota</string>
17
+ <key>IS_ADS_ENABLED</key>
18
+ <false/>
19
+ <key>IS_ANALYTICS_ENABLED</key>
20
+ <false/>
21
+ <key>IS_APPINVITE_ENABLED</key>
22
+ <false/>
23
+ <key>IS_GCM_ENABLED</key>
24
+ <true/>
25
+ <key>IS_SIGNIN_ENABLED</key>
26
+ <false/>
27
+ <key>PLIST_VERSION</key>
28
+ <string>1</string>
29
+ </dict>
30
+ </plist>
@@ -0,0 +1,22 @@
1
+ {
2
+ "project_info": {
3
+ "project_number": "REPLACE_ME",
4
+ "project_id": "REPLACE_ME",
5
+ "storage_bucket": ""
6
+ },
7
+ "client": [
8
+ {
9
+ "client_info": {
10
+ "mobilesdk_app_id": "REPLACE_ME",
11
+ "android_client_info": {
12
+ "package_name": "ir.appsonair.ota"
13
+ }
14
+ },
15
+ "api_key": [
16
+ {
17
+ "current_key": "REPLACE_ME"
18
+ }
19
+ ]
20
+ }
21
+ ]
22
+ }
@@ -2,6 +2,8 @@
2
2
  #import <React/RCTLog.h>
3
3
  #import <React/RCTReloadCommand.h>
4
4
  #import <UIKit/UIKit.h>
5
+ #import <UserNotifications/UserNotifications.h>
6
+ #import <objc/message.h>
5
7
 
6
8
  static NSString *const kBundlePathKey = @"ota_updater_bundle_path";
7
9
  static NSString *const kNativeVersionKey = @"ota_updater_native_version";
@@ -107,4 +109,151 @@ RCT_EXPORT_METHOD(getBundlePath:(RCTPromiseResolveBlock)resolve
107
109
  resolve([OTAUpdaterNative otaBundlePath] ?: [NSNull null]);
108
110
  }
109
111
 
112
+ RCT_EXPORT_METHOD(getFcmToken:(RCTPromiseResolveBlock)resolve
113
+ rejecter:(RCTPromiseRejectBlock)reject)
114
+ {
115
+ dispatch_async(dispatch_get_main_queue(), ^{
116
+ [OTAUpdaterNative ensureFirebaseConfigured];
117
+ [OTAUpdaterNative requestPushAuthorizationThen:^{
118
+ [OTAUpdaterNative fetchFirebaseToken:resolve];
119
+ }];
120
+ });
121
+ }
122
+
123
+ + (NSString *)embeddedGoogleServicePlistPath {
124
+ NSBundle *classBundle = [NSBundle bundleForClass:[OTAUpdaterNative class]];
125
+ NSString *direct = [classBundle pathForResource:@"GoogleService-Info" ofType:@"plist"];
126
+ if (direct.length > 0) {
127
+ return direct;
128
+ }
129
+
130
+ NSURL *bundleURL = [classBundle URLForResource:@"OTAFirebaseConfig" withExtension:@"bundle"];
131
+ if (bundleURL != nil) {
132
+ NSBundle *res = [NSBundle bundleWithURL:bundleURL];
133
+ NSString *nested = [res pathForResource:@"GoogleService-Info" ofType:@"plist"];
134
+ if (nested.length > 0) {
135
+ return nested;
136
+ }
137
+ }
138
+
139
+ NSArray<NSBundle *> *bundles = [NSBundle allBundles];
140
+ for (NSBundle *bundle in bundles) {
141
+ NSString *path = [bundle pathForResource:@"GoogleService-Info" ofType:@"plist"];
142
+ if (path.length == 0) {
143
+ continue;
144
+ }
145
+ NSString *appId = [[NSDictionary dictionaryWithContentsOfFile:path] objectForKey:@"GOOGLE_APP_ID"];
146
+ if ([OTAUpdaterNative isUsableFirebaseValue:appId]) {
147
+ return path;
148
+ }
149
+ }
150
+
151
+ return [[NSBundle mainBundle] pathForResource:@"GoogleService-Info" ofType:@"plist"];
152
+ }
153
+
154
+ + (BOOL)isUsableFirebaseValue:(NSString *)value {
155
+ NSString *trimmed = [value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
156
+ return trimmed.length > 0 && ![trimmed isEqualToString:@"REPLACE_ME"];
157
+ }
158
+
159
+ + (void)ensureFirebaseConfigured {
160
+ Class firApp = NSClassFromString(@"FIRApp");
161
+ Class firOptions = NSClassFromString(@"FIROptions");
162
+ if (firApp == nil || firOptions == nil) {
163
+ return;
164
+ }
165
+
166
+ SEL defaultAppSel = NSSelectorFromString(@"defaultApp");
167
+ if ([firApp respondsToSelector:defaultAppSel]) {
168
+ id existing = ((id (*)(id, SEL))objc_msgSend)(firApp, defaultAppSel);
169
+ if (existing != nil) {
170
+ return;
171
+ }
172
+ }
173
+
174
+ NSString *plistPath = [OTAUpdaterNative embeddedGoogleServicePlistPath];
175
+ if (plistPath.length == 0) {
176
+ return;
177
+ }
178
+
179
+ NSDictionary *plist = [NSDictionary dictionaryWithContentsOfFile:plistPath];
180
+ if (![OTAUpdaterNative isUsableFirebaseValue:plist[@"GOOGLE_APP_ID"]] ||
181
+ ![OTAUpdaterNative isUsableFirebaseValue:plist[@"API_KEY"]] ||
182
+ ![OTAUpdaterNative isUsableFirebaseValue:plist[@"GCM_SENDER_ID"]] ||
183
+ ![OTAUpdaterNative isUsableFirebaseValue:plist[@"PROJECT_ID"]]) {
184
+ return;
185
+ }
186
+
187
+ SEL initFileSel = NSSelectorFromString(@"initWithContentsOfFile:");
188
+ if (![firOptions instancesRespondToSelector:initFileSel]) {
189
+ return;
190
+ }
191
+
192
+ id allocated = ((id (*)(id, SEL))objc_msgSend)(firOptions, NSSelectorFromString(@"alloc"));
193
+ id options = ((id (*)(id, SEL, NSString *))objc_msgSend)(allocated, initFileSel, plistPath);
194
+ if (options == nil) {
195
+ return;
196
+ }
197
+
198
+ // Prefer the host app's bundle id so APNs maps to the installed app.
199
+ NSString *hostBundleId = [[NSBundle mainBundle] bundleIdentifier];
200
+ if (hostBundleId.length > 0) {
201
+ SEL setBundleId = NSSelectorFromString(@"setBundleID:");
202
+ if ([options respondsToSelector:setBundleId]) {
203
+ ((void (*)(id, SEL, NSString *))objc_msgSend)(options, setBundleId, hostBundleId);
204
+ }
205
+ }
206
+
207
+ SEL configureWithOptions = NSSelectorFromString(@"configureWithOptions:");
208
+ if ([firApp respondsToSelector:configureWithOptions]) {
209
+ ((void (*)(id, SEL, id))objc_msgSend)(firApp, configureWithOptions, options);
210
+ }
211
+ }
212
+
213
+ + (void)requestPushAuthorizationThen:(void (^)(void))done {
214
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
215
+ UNAuthorizationOptions options =
216
+ UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge;
217
+ [center requestAuthorizationWithOptions:options
218
+ completionHandler:^(BOOL granted, NSError *error) {
219
+ dispatch_async(dispatch_get_main_queue(), ^{
220
+ [[UIApplication sharedApplication] registerForRemoteNotifications];
221
+ if (done) {
222
+ done();
223
+ }
224
+ });
225
+ }];
226
+ }
227
+
228
+ + (void)fetchFirebaseToken:(RCTPromiseResolveBlock)resolve {
229
+ Class messagingCls = NSClassFromString(@"FIRMessaging");
230
+ if (messagingCls == nil) {
231
+ resolve([NSNull null]);
232
+ return;
233
+ }
234
+
235
+ SEL messagingSel = NSSelectorFromString(@"messaging");
236
+ if (![messagingCls respondsToSelector:messagingSel]) {
237
+ resolve([NSNull null]);
238
+ return;
239
+ }
240
+
241
+ id messaging = ((id (*)(id, SEL))objc_msgSend)(messagingCls, messagingSel);
242
+ SEL tokenSel = NSSelectorFromString(@"tokenWithCompletion:");
243
+ if (messaging == nil || ![messaging respondsToSelector:tokenSel]) {
244
+ resolve([NSNull null]);
245
+ return;
246
+ }
247
+
248
+ void (^completion)(NSString *, NSError *) = ^(NSString *token, NSError *error) {
249
+ if (token.length > 0) {
250
+ resolve(token);
251
+ } else {
252
+ resolve([NSNull null]);
253
+ }
254
+ };
255
+
256
+ ((void (*)(id, SEL, void (^)(NSString *, NSError *)))objc_msgSend)(messaging, tokenSel, completion);
257
+ }
258
+
110
259
  @end
@@ -0,0 +1,11 @@
1
+ export interface DeviceProfilePayload {
2
+ platform: string;
3
+ brand?: string;
4
+ manufacturer?: string;
5
+ model?: string;
6
+ osVersion?: string;
7
+ osApiLevel?: string;
8
+ userAgent?: string;
9
+ deviceAbi?: string;
10
+ }
11
+ export declare function getDeviceProfile(): Promise<DeviceProfilePayload>;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getDeviceProfile = getDeviceProfile;
7
+ const react_native_1 = require("react-native");
8
+ const react_native_device_info_1 = __importDefault(require("react-native-device-info"));
9
+ async function safeString(loader) {
10
+ try {
11
+ const value = await loader();
12
+ if (value == null) {
13
+ return undefined;
14
+ }
15
+ const text = String(value).trim();
16
+ return text || undefined;
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ async function getDeviceProfile() {
23
+ const brand = react_native_device_info_1.default.getBrand?.() || undefined;
24
+ const model = react_native_device_info_1.default.getModel?.() || undefined;
25
+ const osVersion = react_native_device_info_1.default.getSystemVersion?.() || undefined;
26
+ const platform = react_native_1.Platform.OS;
27
+ const [manufacturer, userAgentFromDevice, osApiLevel, deviceAbi] = await Promise.all([
28
+ safeString(() => react_native_device_info_1.default.getManufacturer()),
29
+ safeString(() => react_native_device_info_1.default.getUserAgent()),
30
+ platform === 'android'
31
+ ? safeString(() => react_native_device_info_1.default.getApiLevel())
32
+ : Promise.resolve(undefined),
33
+ platform === 'android'
34
+ ? safeString(async () => {
35
+ const native = react_native_1.NativeModules.OTAUpdaterNative;
36
+ if (!native?.getDeviceAbi) {
37
+ return undefined;
38
+ }
39
+ return native.getDeviceAbi();
40
+ })
41
+ : Promise.resolve(undefined),
42
+ ]);
43
+ const identity = [brand, model].filter(Boolean).join(' ');
44
+ const userAgent = userAgentFromDevice ||
45
+ `OTAUpdater/${react_native_device_info_1.default.getVersion()} (${platform} ${osVersion ?? '?'}; ${identity || 'unknown'}${manufacturer ? `; ${manufacturer}` : ''})`;
46
+ return {
47
+ platform,
48
+ brand,
49
+ manufacturer,
50
+ model,
51
+ osVersion,
52
+ osApiLevel,
53
+ userAgent,
54
+ deviceAbi,
55
+ };
56
+ }
@@ -0,0 +1,10 @@
1
+ import type { AnalyticsEventParams } from './types';
2
+ export type { AnalyticsEventParams };
3
+ /**
4
+ * Log a custom analytics event from anywhere in the app.
5
+ *
6
+ * @example
7
+ * import { logEvent } from '@appsonair.ir/react-native';
8
+ * logEvent('checkout_started', { cartValue: 12 });
9
+ */
10
+ export declare function logEvent(name: string, params?: AnalyticsEventParams): Promise<void>;
package/lib/events.js ADDED
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.logEvent = logEvent;
7
+ const react_native_device_info_1 = __importDefault(require("react-native-device-info"));
8
+ const config_1 = require("./config");
9
+ const sync_1 = require("./sync");
10
+ const MAX_NAME_LENGTH = 128;
11
+ const MAX_PARAM_KEYS = 20;
12
+ const MAX_PARAM_VALUE_LENGTH = 256;
13
+ function sanitizeParams(params) {
14
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
15
+ return undefined;
16
+ }
17
+ const out = {};
18
+ for (const [key, value] of Object.entries(params)) {
19
+ if (Object.keys(out).length >= MAX_PARAM_KEYS) {
20
+ break;
21
+ }
22
+ const name = key.trim().slice(0, 64);
23
+ if (!name) {
24
+ continue;
25
+ }
26
+ if (typeof value === 'number') {
27
+ if (!Number.isFinite(value)) {
28
+ continue;
29
+ }
30
+ out[name] = value;
31
+ continue;
32
+ }
33
+ if (typeof value === 'boolean') {
34
+ out[name] = value;
35
+ continue;
36
+ }
37
+ if (typeof value === 'string') {
38
+ out[name] = value.slice(0, MAX_PARAM_VALUE_LENGTH);
39
+ }
40
+ }
41
+ return Object.keys(out).length > 0 ? out : undefined;
42
+ }
43
+ /**
44
+ * Log a custom analytics event from anywhere in the app.
45
+ *
46
+ * @example
47
+ * import { logEvent } from '@appsonair.ir/react-native';
48
+ * logEvent('checkout_started', { cartValue: 12 });
49
+ */
50
+ async function logEvent(name, params) {
51
+ const config = (0, config_1.getConfig)();
52
+ if (!config) {
53
+ return;
54
+ }
55
+ const eventName = String(name ?? '')
56
+ .trim()
57
+ .slice(0, MAX_NAME_LENGTH);
58
+ if (!eventName) {
59
+ return;
60
+ }
61
+ try {
62
+ const clientUniqueId = await (0, sync_1.getClientUniqueId)();
63
+ await fetch(`${(0, config_1.getServerUrl)(config)}/api/devices/event`, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ 'X-Deployment-Key': config.deploymentKey,
68
+ },
69
+ body: JSON.stringify({
70
+ name: eventName,
71
+ params: sanitizeParams(params),
72
+ clientUniqueId,
73
+ appVersion: react_native_device_info_1.default.getVersion(),
74
+ }),
75
+ });
76
+ }
77
+ catch {
78
+ // Analytics must never break the host app.
79
+ }
80
+ }
package/lib/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { configure, getConfig } from './config';
2
2
  import { configureFromProject, getDeploymentKey, isOtaConfigured, resolveUpdaterConfig } from './projectConfig';
3
3
  import { sync, restartApp, installApk, resumePendingApkInstall, clearUpdate, getInstalledBundlePath, notifyAppReady } from './sync';
4
+ import { logEvent } from './events';
5
+ import { ping, startTelemetry, stopTelemetry } from './telemetry';
4
6
  import { UpdateCheckResult } from './types';
5
7
  export { configure, getConfig, getServerUrl } from './config';
6
8
  export { DEFAULT_SERVER_URL } from './constants';
@@ -9,6 +11,8 @@ export type { ProjectOtaConfig } from './types';
9
11
  export declare function getCurrentLabel(): Promise<string | null>;
10
12
  export declare function checkUpdate(): Promise<UpdateCheckResult>;
11
13
  export { sync, restartApp, installApk, resumePendingApkInstall, clearUpdate, getInstalledBundlePath, notifyAppReady, isSyncInProgress, respondToUpdatePrompt, } from './sync';
14
+ export { ping, startTelemetry, stopTelemetry } from './telemetry';
15
+ export { logEvent } from './events';
12
16
  export { OTAUpdateModal } from './ui/OTAUpdateModal';
13
17
  export { OTAUpdateProvider } from './ui/OTAUpdateProvider';
14
18
  export declare const OTAUpdater: {
@@ -23,5 +27,9 @@ export declare const OTAUpdater: {
23
27
  clearUpdate: typeof clearUpdate;
24
28
  getInstalledBundlePath: typeof getInstalledBundlePath;
25
29
  notifyAppReady: typeof notifyAppReady;
30
+ ping: typeof ping;
31
+ startTelemetry: typeof startTelemetry;
32
+ stopTelemetry: typeof stopTelemetry;
33
+ logEvent: typeof logEvent;
26
34
  };
27
35
  export * from './types';
package/lib/index.js CHANGED
@@ -17,7 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
17
17
  return (mod && mod.__esModule) ? mod : { "default": mod };
18
18
  };
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.OTAUpdater = exports.OTAUpdateProvider = exports.OTAUpdateModal = exports.respondToUpdatePrompt = exports.isSyncInProgress = exports.notifyAppReady = exports.getInstalledBundlePath = exports.clearUpdate = exports.resumePendingApkInstall = exports.installApk = exports.restartApp = exports.sync = exports.resolveUpdaterConfig = exports.isOtaConfigured = exports.getDeploymentKey = exports.configureFromProject = exports.DEFAULT_SERVER_URL = exports.getServerUrl = exports.getConfig = exports.configure = void 0;
20
+ exports.OTAUpdater = exports.OTAUpdateProvider = exports.OTAUpdateModal = exports.logEvent = exports.stopTelemetry = exports.startTelemetry = exports.ping = exports.respondToUpdatePrompt = exports.isSyncInProgress = exports.notifyAppReady = exports.getInstalledBundlePath = exports.clearUpdate = exports.resumePendingApkInstall = exports.installApk = exports.restartApp = exports.sync = exports.resolveUpdaterConfig = exports.isOtaConfigured = exports.getDeploymentKey = exports.configureFromProject = exports.DEFAULT_SERVER_URL = exports.getServerUrl = exports.getConfig = exports.configure = void 0;
21
21
  exports.getCurrentLabel = getCurrentLabel;
22
22
  exports.checkUpdate = checkUpdate;
23
23
  const react_native_device_info_1 = __importDefault(require("react-native-device-info"));
@@ -29,6 +29,8 @@ Object.defineProperty(exports, "getDeploymentKey", { enumerable: true, get: func
29
29
  Object.defineProperty(exports, "isOtaConfigured", { enumerable: true, get: function () { return projectConfig_1.isOtaConfigured; } });
30
30
  Object.defineProperty(exports, "resolveUpdaterConfig", { enumerable: true, get: function () { return projectConfig_1.resolveUpdaterConfig; } });
31
31
  const sync_1 = require("./sync");
32
+ const events_1 = require("./events");
33
+ const telemetry_1 = require("./telemetry");
32
34
  var config_2 = require("./config");
33
35
  Object.defineProperty(exports, "configure", { enumerable: true, get: function () { return config_2.configure; } });
34
36
  Object.defineProperty(exports, "getConfig", { enumerable: true, get: function () { return config_2.getConfig; } });
@@ -60,6 +62,12 @@ Object.defineProperty(exports, "getInstalledBundlePath", { enumerable: true, get
60
62
  Object.defineProperty(exports, "notifyAppReady", { enumerable: true, get: function () { return sync_2.notifyAppReady; } });
61
63
  Object.defineProperty(exports, "isSyncInProgress", { enumerable: true, get: function () { return sync_2.isSyncInProgress; } });
62
64
  Object.defineProperty(exports, "respondToUpdatePrompt", { enumerable: true, get: function () { return sync_2.respondToUpdatePrompt; } });
65
+ var telemetry_2 = require("./telemetry");
66
+ Object.defineProperty(exports, "ping", { enumerable: true, get: function () { return telemetry_2.ping; } });
67
+ Object.defineProperty(exports, "startTelemetry", { enumerable: true, get: function () { return telemetry_2.startTelemetry; } });
68
+ Object.defineProperty(exports, "stopTelemetry", { enumerable: true, get: function () { return telemetry_2.stopTelemetry; } });
69
+ var events_2 = require("./events");
70
+ Object.defineProperty(exports, "logEvent", { enumerable: true, get: function () { return events_2.logEvent; } });
63
71
  var OTAUpdateModal_1 = require("./ui/OTAUpdateModal");
64
72
  Object.defineProperty(exports, "OTAUpdateModal", { enumerable: true, get: function () { return OTAUpdateModal_1.OTAUpdateModal; } });
65
73
  var OTAUpdateProvider_1 = require("./ui/OTAUpdateProvider");
@@ -76,5 +84,9 @@ exports.OTAUpdater = {
76
84
  clearUpdate: sync_1.clearUpdate,
77
85
  getInstalledBundlePath: sync_1.getInstalledBundlePath,
78
86
  notifyAppReady: sync_1.notifyAppReady,
87
+ ping: telemetry_1.ping,
88
+ startTelemetry: telemetry_1.startTelemetry,
89
+ stopTelemetry: telemetry_1.stopTelemetry,
90
+ logEvent: events_1.logEvent,
79
91
  };
80
92
  __exportStar(require("./types"), exports);
package/lib/sync.d.ts CHANGED
@@ -25,6 +25,7 @@ export declare const defaultMessages: {
25
25
  installing: string;
26
26
  restarting: string;
27
27
  error: string;
28
+ downloadInterrupted: string;
28
29
  upToDate: string;
29
30
  updateAvailable: string;
30
31
  changelogTitle: string;
@@ -47,6 +48,8 @@ export declare function clearStoredBundleMeta(): Promise<void>;
47
48
  export declare function markRestartPending(): Promise<void>;
48
49
  export declare function shouldSkipSyncAfterRestart(): Promise<boolean>;
49
50
  export declare function getDeviceAbi(): Promise<string | undefined>;
51
+ /** FCM token from the library Firebase project (shared config, native). */
52
+ export declare function getFcmToken(): Promise<string | undefined>;
50
53
  export declare function checkForUpdate(serverUrl: string, deploymentKey: string, appVersion: string, label?: string, packageHash?: string, clientUniqueId?: string, buildNumber?: string, deviceAbi?: string): Promise<UpdateCheckResult>;
51
54
  export declare function getBundleReleaseDir(label: string): string;
52
55
  export declare function getBundleFilePath(label: string): string;
package/lib/sync.js CHANGED
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -18,6 +51,7 @@ exports.clearStoredBundleMeta = clearStoredBundleMeta;
18
51
  exports.markRestartPending = markRestartPending;
19
52
  exports.shouldSkipSyncAfterRestart = shouldSkipSyncAfterRestart;
20
53
  exports.getDeviceAbi = getDeviceAbi;
54
+ exports.getFcmToken = getFcmToken;
21
55
  exports.checkForUpdate = checkForUpdate;
22
56
  exports.getBundleReleaseDir = getBundleReleaseDir;
23
57
  exports.getBundleFilePath = getBundleFilePath;
@@ -37,6 +71,7 @@ const react_native_1 = require("react-native");
37
71
  const react_native_device_info_1 = __importDefault(require("react-native-device-info"));
38
72
  const config_1 = require("./config");
39
73
  const zipExtract_1 = require("./zipExtract");
74
+ const deviceProfile_1 = require("./deviceProfile");
40
75
  const types_1 = require("./types");
41
76
  const initialProgressState = {
42
77
  visible: false,
@@ -75,6 +110,7 @@ exports.defaultMessages = {
75
110
  installing: 'در حال نصب به\u200cروزرسانی...',
76
111
  restarting: 'در حال راه\u200cاندازی مجدد...',
77
112
  error: 'خطا در به\u200cروزرسانی',
113
+ downloadInterrupted: 'دانلود قطع شد. لطفاً دوباره تلاش کنید',
78
114
  upToDate: 'برنامه به\u200cروز است',
79
115
  updateAvailable: 'نسخه جدیدی در دسترس است',
80
116
  changelogTitle: 'تغییرات این نسخه',
@@ -254,8 +290,25 @@ async function getDeviceAbi() {
254
290
  }
255
291
  return undefined;
256
292
  }
293
+ /** FCM token from the library Firebase project (shared config, native). */
294
+ async function getFcmToken() {
295
+ if (!OTAUpdaterNative?.getFcmToken) {
296
+ return undefined;
297
+ }
298
+ try {
299
+ const token = await OTAUpdaterNative.getFcmToken();
300
+ return typeof token === 'string' && token.trim() ? token.trim() : undefined;
301
+ }
302
+ catch {
303
+ return undefined;
304
+ }
305
+ }
257
306
  async function checkForUpdate(serverUrl, deploymentKey, appVersion, label, packageHash, clientUniqueId, buildNumber, deviceAbi) {
258
307
  const baseUrl = serverUrl.replace(/\/$/, '');
308
+ const [profile, fcmToken] = await Promise.all([
309
+ (0, deviceProfile_1.getDeviceProfile)(),
310
+ getFcmToken(),
311
+ ]);
259
312
  const response = await fetch(`${baseUrl}/api/update-check`, {
260
313
  method: 'POST',
261
314
  headers: {
@@ -268,7 +321,9 @@ async function checkForUpdate(serverUrl, deploymentKey, appVersion, label, packa
268
321
  packageHash,
269
322
  clientUniqueId,
270
323
  buildNumber,
271
- deviceAbi,
324
+ ...profile,
325
+ deviceAbi: deviceAbi ?? profile.deviceAbi,
326
+ fcmToken,
272
327
  }),
273
328
  });
274
329
  if (!response.ok) {
@@ -413,6 +468,31 @@ async function ensureBundleDirectory() {
413
468
  await ReactNativeBlobUtil.fs.mkdir(dir);
414
469
  }
415
470
  }
471
+ /** Keep only the active OTA package so leftover extracts don't fill disk / slow IO. */
472
+ async function cleanupOldUpdates(keepLabel) {
473
+ try {
474
+ const ReactNativeBlobUtil = require('react-native-blob-util').default;
475
+ const dir = getBundleDirectory();
476
+ const exists = await ReactNativeBlobUtil.fs.exists(dir);
477
+ if (!exists)
478
+ return;
479
+ const keep = new Set([
480
+ keepLabel,
481
+ `${keepLabel}.zip`,
482
+ `${keepLabel}.bundle`,
483
+ ]);
484
+ const entries = await ReactNativeBlobUtil.fs.ls(dir);
485
+ await Promise.all(entries.map(async (entry) => {
486
+ if (keep.has(entry) || entry.endsWith('.apk')) {
487
+ return;
488
+ }
489
+ await removePathRecursive(`${dir}/${entry}`);
490
+ }));
491
+ }
492
+ catch {
493
+ // Best-effort cleanup; a leftover folder must not fail install.
494
+ }
495
+ }
416
496
  async function resolveDownloadSize(downloadUrl, deploymentKey, expectedSize, deviceAbi) {
417
497
  if (expectedSize && expectedSize > 0) {
418
498
  return expectedSize;
@@ -449,6 +529,80 @@ async function resolveDownloadSize(downloadUrl, deploymentKey, expectedSize, dev
449
529
  }
450
530
  return expectedSize ?? 0;
451
531
  }
532
+ const DOWNLOAD_MAX_ATTEMPTS = 3;
533
+ function getErrorMessage(error) {
534
+ if (error instanceof Error)
535
+ return error.message;
536
+ if (typeof error === 'string')
537
+ return error;
538
+ if (error && typeof error === 'object' && 'message' in error) {
539
+ return String(error.message);
540
+ }
541
+ return String(error);
542
+ }
543
+ function isRetryableDownloadError(error) {
544
+ const message = getErrorMessage(error).toLowerCase();
545
+ return (message.includes('download interrupted') ||
546
+ message.includes('network') ||
547
+ message.includes('timeout') ||
548
+ message.includes('timed out') ||
549
+ message.includes('connection') ||
550
+ message.includes('socket') ||
551
+ message.includes('econnreset') ||
552
+ message.includes('failed to connect') ||
553
+ message.includes('software caused connection abort'));
554
+ }
555
+ function toUserFacingDownloadError(error) {
556
+ if (isRetryableDownloadError(error)) {
557
+ return new Error(exports.defaultMessages.downloadInterrupted);
558
+ }
559
+ return error instanceof Error ? error : new Error(getErrorMessage(error));
560
+ }
561
+ async function cleanupPartialDownload(path) {
562
+ try {
563
+ const ReactNativeBlobUtil = require('react-native-blob-util').default;
564
+ const exists = await ReactNativeBlobUtil.fs.exists(path);
565
+ if (exists) {
566
+ await ReactNativeBlobUtil.fs.unlink(path);
567
+ }
568
+ }
569
+ catch {
570
+ // Ignore cleanup errors.
571
+ }
572
+ }
573
+ /**
574
+ * react-native-blob-util rejects with "Download interrupted." whenever
575
+ * bytesWritten !== Content-Length — including when Content-Length is wrong
576
+ * but the full file actually landed. Recover those cases via size/hash checks.
577
+ */
578
+ async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label) {
579
+ try {
580
+ const ReactNativeBlobUtil = require('react-native-blob-util').default;
581
+ const exists = await ReactNativeBlobUtil.fs.exists(destPath);
582
+ if (!exists)
583
+ return null;
584
+ const stat = await ReactNativeBlobUtil.fs.stat(destPath);
585
+ const minSize = extension === 'apk' ? 1024 * 100 : 1024;
586
+ if (!stat || stat.size < minSize)
587
+ return null;
588
+ const sizeLooksComplete = totalBytes <= 0 || Math.abs(stat.size - totalBytes) <= 1;
589
+ if (expectedHash) {
590
+ const valid = await verifyBundleHash(destPath, expectedHash);
591
+ if (!valid)
592
+ return null;
593
+ }
594
+ else if (!sizeLooksComplete) {
595
+ return null;
596
+ }
597
+ if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
598
+ return extractBundleUpdate(label, destPath);
599
+ }
600
+ return destPath;
601
+ }
602
+ catch {
603
+ return null;
604
+ }
605
+ }
452
606
  async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, onProgress, extension = 'bundle', deviceAbi, packageFormat = 'bundle', expectedHash) {
453
607
  const ReactNativeBlobUtil = require('react-native-blob-util').default;
454
608
  const isZipBundle = extension === 'bundle' && packageFormat === 'zip';
@@ -462,76 +616,95 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
462
616
  if (isZipBundle) {
463
617
  await removePathRecursive(getBundleReleaseDir(label));
464
618
  }
465
- const destExists = await ReactNativeBlobUtil.fs.exists(destPath);
466
- if (destExists) {
467
- await ReactNativeBlobUtil.fs.unlink(destPath);
468
- }
469
- const task = ReactNativeBlobUtil.config({
470
- path: destPath,
471
- fileCache: false,
472
- overwrite: true,
473
- }).fetch('GET', downloadUrl, {
474
- 'X-Deployment-Key': deploymentKey,
475
- ...(deviceAbi ? { 'X-Device-Abi': deviceAbi } : {}),
476
- });
477
- const reportDownloadProgress = (received, total) => {
478
- if (!onProgress)
479
- return;
480
- const resolvedTotal = total > 0 ? total : totalBytes;
481
- if (resolvedTotal <= 0)
482
- return;
483
- onProgress(Math.min(Math.max(received / resolvedTotal, 0), 0.99));
484
- };
485
- let pollTimer = null;
486
- if (onProgress && totalBytes > 0) {
487
- pollTimer = setInterval(async () => {
488
- try {
489
- const exists = await ReactNativeBlobUtil.fs.exists(destPath);
490
- if (!exists)
491
- return;
492
- const stat = await ReactNativeBlobUtil.fs.stat(destPath);
493
- reportDownloadProgress(stat.size, totalBytes);
494
- }
495
- catch {
496
- // Ignore polling errors while the file is being written.
497
- }
498
- }, 150);
499
- }
500
- if (onProgress) {
501
- task.progress({ interval: 100, count: 10 }, (received, total) => {
502
- reportDownloadProgress(received, total);
619
+ let lastError;
620
+ for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
621
+ await cleanupPartialDownload(destPath);
622
+ const task = ReactNativeBlobUtil.config({
623
+ path: destPath,
624
+ fileCache: false,
625
+ overwrite: true,
626
+ timeout: 10 * 60 * 1000,
627
+ }).fetch('GET', downloadUrl, {
628
+ 'X-Deployment-Key': deploymentKey,
629
+ ...(deviceAbi ? { 'X-Device-Abi': deviceAbi } : {}),
503
630
  });
504
- }
505
- try {
506
- const response = await task;
507
- if (onProgress) {
508
- onProgress(1);
509
- }
510
- const downloadedPath = String(response.path()).replace(/^file:\/\//, '');
511
- if (downloadedPath !== destPath) {
512
- await ReactNativeBlobUtil.fs.cp(downloadedPath, destPath);
631
+ const reportDownloadProgress = (received, total) => {
632
+ if (!onProgress)
633
+ return;
634
+ const resolvedTotal = total > 0 ? total : totalBytes;
635
+ if (resolvedTotal <= 0)
636
+ return;
637
+ onProgress(Math.min(Math.max(received / resolvedTotal, 0), 0.99));
638
+ };
639
+ let pollTimer = null;
640
+ if (onProgress && totalBytes > 0) {
641
+ pollTimer = setInterval(async () => {
642
+ try {
643
+ const exists = await ReactNativeBlobUtil.fs.exists(destPath);
644
+ if (!exists)
645
+ return;
646
+ const stat = await ReactNativeBlobUtil.fs.stat(destPath);
647
+ reportDownloadProgress(stat.size, totalBytes);
648
+ }
649
+ catch {
650
+ // Ignore polling errors while the file is being written.
651
+ }
652
+ }, 150);
513
653
  }
514
- const stat = await ReactNativeBlobUtil.fs.stat(destPath);
515
- const minSize = extension === 'apk' ? 1024 * 100 : 1024;
516
- if (!stat || stat.size < minSize) {
517
- throw new Error(`Downloaded file is too small (${stat?.size ?? 0} bytes)`);
654
+ if (onProgress) {
655
+ task.progress({ interval: 100, count: 10 }, (received, total) => {
656
+ reportDownloadProgress(received, total);
657
+ });
518
658
  }
519
- if (expectedHash) {
520
- const valid = await verifyBundleHash(destPath, expectedHash);
521
- if (!valid) {
522
- throw new Error('Package hash verification failed');
659
+ try {
660
+ const response = await task;
661
+ if (onProgress) {
662
+ onProgress(1);
663
+ }
664
+ const downloadedPath = String(response.path()).replace(/^file:\/\//, '');
665
+ if (downloadedPath !== destPath) {
666
+ await ReactNativeBlobUtil.fs.cp(downloadedPath, destPath);
523
667
  }
668
+ const stat = await ReactNativeBlobUtil.fs.stat(destPath);
669
+ const minSize = extension === 'apk' ? 1024 * 100 : 1024;
670
+ if (!stat || stat.size < minSize) {
671
+ throw new Error(`Downloaded file is too small (${stat?.size ?? 0} bytes)`);
672
+ }
673
+ if (expectedHash) {
674
+ const valid = await verifyBundleHash(destPath, expectedHash);
675
+ if (!valid) {
676
+ throw new Error('Package hash verification failed');
677
+ }
678
+ }
679
+ if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
680
+ return extractBundleUpdate(label, destPath);
681
+ }
682
+ return destPath;
524
683
  }
525
- if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
526
- return extractBundleUpdate(label, destPath);
684
+ catch (error) {
685
+ lastError = error;
686
+ if (isRetryableDownloadError(error)) {
687
+ const recovered = await tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label);
688
+ if (recovered) {
689
+ if (onProgress)
690
+ onProgress(1);
691
+ return recovered;
692
+ }
693
+ }
694
+ await cleanupPartialDownload(destPath);
695
+ if (attempt < DOWNLOAD_MAX_ATTEMPTS && isRetryableDownloadError(error)) {
696
+ await delay(attempt * 750);
697
+ continue;
698
+ }
699
+ throw toUserFacingDownloadError(error);
527
700
  }
528
- return destPath;
529
- }
530
- finally {
531
- if (pollTimer) {
532
- clearInterval(pollTimer);
701
+ finally {
702
+ if (pollTimer) {
703
+ clearInterval(pollTimer);
704
+ }
533
705
  }
534
706
  }
707
+ throw toUserFacingDownloadError(lastError);
535
708
  }
536
709
  async function verifyBundleHash(filePath, expectedHash) {
537
710
  const ReactNativeBlobUtil = require('react-native-blob-util').default;
@@ -725,6 +898,9 @@ async function performSync(options = {}, onProgress, messages) {
725
898
  packageHash: update.packageHash,
726
899
  installedAt: new Date().toISOString(),
727
900
  });
901
+ void Promise.resolve().then(() => __importStar(require('./telemetry'))).then((mod) => mod.ping())
902
+ .catch(() => { });
903
+ await cleanupOldUpdates(update.label);
728
904
  const shouldRestartNow = installMode === 'immediate' || update.isMandatory === true;
729
905
  if (shouldRestartNow) {
730
906
  reportProgress(showUI, messages, onProgress, types_1.SyncStatus.RESTARTING, 1, {
@@ -793,12 +969,8 @@ async function performApkSync(update, showUI, messages, onProgress, changelog) {
793
969
  label: update.label,
794
970
  description: notes,
795
971
  isMandatory: update.isMandatory,
796
- }), 'apk', update.deviceAbi);
972
+ }), 'apk', update.deviceAbi, 'bundle', update.packageHash);
797
973
  onProgress?.(types_1.SyncStatus.INSTALLING, 1);
798
- const valid = await verifyBundleHash(apkPath, update.packageHash);
799
- if (!valid) {
800
- throw new Error('APK hash verification failed');
801
- }
802
974
  // Drop the OTA JS path before opening the installer so the new APK does not
803
975
  // keep loading the previous bundle on first launch after install.
804
976
  await clearUpdate();
@@ -855,10 +1027,16 @@ async function getInstalledBundlePath() {
855
1027
  return null;
856
1028
  }
857
1029
  async function clearUpdate() {
1030
+ const meta = await getStoredBundleMeta();
858
1031
  await clearStoredBundleMeta();
859
1032
  if (OTAUpdaterNative?.clearBundlePath) {
860
1033
  await OTAUpdaterNative.clearBundlePath();
861
1034
  }
1035
+ if (meta?.label) {
1036
+ await removePathRecursive(getBundleReleaseDir(meta.label));
1037
+ await cleanupPartialDownload(getBundleZipPath(meta.label));
1038
+ await cleanupPartialDownload(getLegacyBundleFilePath(meta.label));
1039
+ }
862
1040
  }
863
1041
  async function notifyAppReady() {
864
1042
  const meta = await getStoredBundleMeta();
@@ -0,0 +1,6 @@
1
+ export interface TelemetryPingOptions {
2
+ sessionStart?: boolean;
3
+ }
4
+ export declare function ping(options?: TelemetryPingOptions): Promise<void>;
5
+ export declare function startTelemetry(): () => void;
6
+ export declare function stopTelemetry(): void;
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ping = ping;
7
+ exports.startTelemetry = startTelemetry;
8
+ exports.stopTelemetry = stopTelemetry;
9
+ const react_native_1 = require("react-native");
10
+ const react_native_device_info_1 = __importDefault(require("react-native-device-info"));
11
+ const config_1 = require("./config");
12
+ const deviceProfile_1 = require("./deviceProfile");
13
+ const sync_1 = require("./sync");
14
+ const PING_INTERVAL_MS = 60000;
15
+ let started = false;
16
+ let pingTimer = null;
17
+ let appStateSub = null;
18
+ let pingInFlight = null;
19
+ async function buildPingBody(sessionStart) {
20
+ const config = (0, config_1.getConfig)();
21
+ if (!config) {
22
+ return null;
23
+ }
24
+ const [clientUniqueId, meta, profile, fcmToken] = await Promise.all([
25
+ (0, sync_1.getClientUniqueId)(),
26
+ (0, sync_1.getStoredBundleMeta)(),
27
+ (0, deviceProfile_1.getDeviceProfile)(),
28
+ (0, sync_1.getFcmToken)(),
29
+ ]);
30
+ return {
31
+ clientUniqueId,
32
+ appVersion: react_native_device_info_1.default.getVersion(),
33
+ buildNumber: react_native_device_info_1.default.getBuildNumber(),
34
+ ...profile,
35
+ label: meta?.label,
36
+ packageHash: meta?.packageHash,
37
+ fcmToken,
38
+ sessionStart,
39
+ };
40
+ }
41
+ async function ping(options = {}) {
42
+ const config = (0, config_1.getConfig)();
43
+ if (!config) {
44
+ return;
45
+ }
46
+ if (pingInFlight) {
47
+ return pingInFlight;
48
+ }
49
+ pingInFlight = (async () => {
50
+ try {
51
+ const body = await buildPingBody(options.sessionStart === true);
52
+ if (!body) {
53
+ return;
54
+ }
55
+ const baseUrl = (0, config_1.getServerUrl)(config);
56
+ await fetch(`${baseUrl}/api/devices/ping`, {
57
+ method: 'POST',
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ 'X-Deployment-Key': config.deploymentKey,
61
+ },
62
+ body: JSON.stringify(body),
63
+ });
64
+ }
65
+ catch {
66
+ // Telemetry must never break update sync.
67
+ }
68
+ })().finally(() => {
69
+ pingInFlight = null;
70
+ });
71
+ return pingInFlight;
72
+ }
73
+ function startTelemetry() {
74
+ if (started) {
75
+ return stopTelemetry;
76
+ }
77
+ started = true;
78
+ void ping({ sessionStart: true });
79
+ pingTimer = setInterval(() => {
80
+ void ping();
81
+ }, PING_INTERVAL_MS);
82
+ appStateSub = react_native_1.AppState.addEventListener('change', (nextState) => {
83
+ if (nextState === 'active') {
84
+ void ping();
85
+ }
86
+ });
87
+ return stopTelemetry;
88
+ }
89
+ function stopTelemetry() {
90
+ started = false;
91
+ if (pingTimer) {
92
+ clearInterval(pingTimer);
93
+ pingTimer = null;
94
+ }
95
+ appStateSub?.remove();
96
+ appStateSub = null;
97
+ }
package/lib/types.d.ts CHANGED
@@ -117,3 +117,5 @@ export interface StoredBundleMeta {
117
117
  packageHash: string;
118
118
  installedAt: string;
119
119
  }
120
+ /** Primitive values allowed on custom analytics events. */
121
+ export type AnalyticsEventParams = Record<string, string | number | boolean>;
@@ -39,6 +39,7 @@ const react_native_1 = require("react-native");
39
39
  const projectConfig_1 = require("../projectConfig");
40
40
  const progress_1 = require("../progress");
41
41
  const sync_1 = require("../sync");
42
+ const telemetry_1 = require("../telemetry");
42
43
  const OTAUpdateModal_1 = require("./OTAUpdateModal");
43
44
  const SYNC_DELAY_MS = 2500;
44
45
  const FOREGROUND_SYNC_DEBOUNCE_MS = 30000;
@@ -54,6 +55,11 @@ function OTAUpdateProvider({ children, projectConfig, autoSync = true, skipInDev
54
55
  }
55
56
  }, [projectConfig]);
56
57
  (0, react_1.useEffect)(() => (0, progress_1.subscribeProgress)(setProgress), []);
58
+ (0, react_1.useEffect)(() => {
59
+ if (skipInDev && __DEV__)
60
+ return;
61
+ return (0, telemetry_1.startTelemetry)();
62
+ }, [skipInDev]);
57
63
  (0, react_1.useEffect)(() => {
58
64
  if (!autoSync)
59
65
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsonair.ir/react-native",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "React Native client SDK for self-hosted OTA updates",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -9,6 +9,8 @@
9
9
  "lib",
10
10
  "android",
11
11
  "ios",
12
+ "firebase",
13
+ "appsonair-react-native.podspec",
12
14
  "react-native.config.js"
13
15
  ],
14
16
  "scripts": {