1 package expo.modules.devlauncher.modules
2 
3 import android.content.ActivityNotFoundException
4 import android.content.ClipData
5 import android.content.ClipboardManager
6 import android.content.Context
7 import android.content.Intent
8 import android.content.pm.PackageManager
9 import android.net.Uri
10 import com.facebook.react.bridge.*
11 import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
12 import expo.modules.core.utilities.EmulatorUtilities
13 import expo.modules.devlauncher.DevLauncherController
14 import expo.modules.devlauncher.DevLauncherController.Companion.wasInitialized
15 import expo.modules.devlauncher.helpers.DevLauncherInstallationIDHelper
16 import expo.modules.devlauncher.koin.DevLauncherKoinComponent
17 import expo.modules.devlauncher.launcher.DevLauncherControllerInterface
18 import expo.modules.devlauncher.launcher.DevLauncherIntentRegistryInterface
19 import expo.modules.devlauncher.launcher.errors.DevLauncherErrorRegistry
20 import expo.modules.devmenu.DevMenuManager
21 import kotlinx.coroutines.launch
22 import org.koin.core.component.inject
23 
24 private const val ON_NEW_DEEP_LINK_EVENT = "expo.modules.devlauncher.onnewdeeplink"
25 private const val CLIENT_PACKAGE_NAME = "host.exp.exponent"
26 private val CLIENT_HOME_QR_SCANNER_DEEP_LINK = Uri.parse("expo-home://qr-scanner")
27 private const val LAUNCHER_NAVIGATION_STATE_KEY = "expo.modules.devlauncher.navigation-state"
28 
29 class DevLauncherInternalModule(reactContext: ReactApplicationContext?) :
30   ReactContextBaseJavaModule(reactContext), DevLauncherKoinComponent {
31   private val controller: DevLauncherControllerInterface by inject()
32   private val intentRegistry: DevLauncherIntentRegistryInterface by inject()
33   private val installationIDHelper: DevLauncherInstallationIDHelper by inject()
34 
initializenull35   override fun initialize() {
36     super.initialize()
37     if (wasInitialized()) {
38       intentRegistry.subscribe(this::onNewPendingIntent)
39     }
40   }
41 
invalidatenull42   override fun invalidate() {
43     super.invalidate()
44     if (wasInitialized()) {
45       intentRegistry.unsubscribe(this::onNewPendingIntent)
46     }
47   }
48 
getNamenull49   override fun getName() = "EXDevLauncherInternal"
50 
51   override fun getConstants(): Map<String, Any> {
52     val isRunningOnEmulator = EmulatorUtilities.isRunningOnEmulator()
53     return mapOf(
54       "installationID" to installationIDHelper.getOrCreateInstallationID(reactApplicationContext),
55       "isDevice" to !isRunningOnEmulator,
56       "updatesConfig" to getUpdatesConfig(),
57     )
58   }
59 
getUpdatesConfignull60   private fun getUpdatesConfig(): WritableMap {
61     val map = Arguments.createMap()
62 
63     val runtimeVersion = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_RUNTIME_VERSION")
64     val sdkVersion = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_SDK_VERSION")
65     var projectUrl = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_UPDATE_URL")
66 
67     val appId = if (projectUrl.isNotEmpty()) {
68       Uri.parse(projectUrl).lastPathSegment ?: ""
69     } else {
70       ""
71     }
72 
73     val projectUri = Uri.parse(projectUrl)
74 
75     val isModernManifestProtocol = projectUri.host.equals("u.expo.dev") || projectUri.host.equals("staging-u.expo.dev")
76     val usesEASUpdates = isModernManifestProtocol && appId.isNotEmpty()
77 
78     return map.apply {
79       putString("appId", appId)
80       putString("runtimeVersion", runtimeVersion)
81       putString("sdkVersion", sdkVersion)
82       putBoolean("usesEASUpdates", usesEASUpdates)
83       putString("projectUrl", projectUrl)
84     }
85   }
86 
sanitizeUrlStringnull87   private fun sanitizeUrlString(url: String): Uri {
88     var sanitizedUrl = url.trim()
89     // If the url does contain a scheme use "http://"
90     if(!sanitizedUrl.contains("://")) {
91       sanitizedUrl = "http://" + sanitizedUrl
92     }
93 
94     return Uri.parse(sanitizedUrl)
95   }
96 
97   @ReactMethod
loadUpdatenull98   fun loadUpdate(url: String, projectUrlString: String?, promise: Promise) {
99     controller.coroutineScope.launch {
100       try {
101         val appUrl = sanitizeUrlString(url)
102         var projectUrl: Uri? = null
103         if (projectUrlString != null) {
104           projectUrl = sanitizeUrlString(projectUrlString)
105         }
106         controller.loadApp(appUrl, projectUrl)
107       } catch (e: Exception) {
108         promise.reject("ERR_DEV_LAUNCHER_CANNOT_LOAD_APP", e.message, e)
109         return@launch
110       }
111       promise.resolve(null)
112     }
113   }
114 
115   @ReactMethod
loadAppnull116   fun loadApp(url: String, promise: Promise) {
117     controller.coroutineScope.launch {
118       try {
119         val parsedUrl = sanitizeUrlString(url)
120         controller.loadApp(parsedUrl)
121       } catch (e: Exception) {
122         promise.reject("ERR_DEV_LAUNCHER_CANNOT_LOAD_APP", e.message, e)
123         return@launch
124       }
125       promise.resolve(null)
126     }
127   }
128 
129   @ReactMethod
getRecentlyOpenedAppsnull130   fun getRecentlyOpenedApps(promise: Promise) {
131     val apps = Arguments.createArray()
132 
133     for (recentlyOpenedApp in controller.getRecentlyOpenedApps()) {
134       val app = Arguments.createMap()
135 
136       app.putDouble("timestamp", recentlyOpenedApp.timestamp.toDouble())
137       app.putString("name", recentlyOpenedApp.name)
138       app.putString("url", recentlyOpenedApp.url)
139       app.putBoolean("isEASUpdate", recentlyOpenedApp.isEASUpdate == true)
140 
141       if (recentlyOpenedApp.isEASUpdate == true) {
142         app.putString("updateMessage", recentlyOpenedApp.updateMessage)
143         app.putString("branchName", recentlyOpenedApp.branchName)
144       }
145 
146       apps.pushMap(app)
147     }
148 
149     return promise.resolve(apps)
150   }
151 
152   @ReactMethod
clearRecentlyOpenedAppsnull153   fun clearRecentlyOpenedApps(promise: Promise) {
154     controller.clearRecentlyOpenedApps()
155     return promise.resolve(null)
156   }
157 
158   @ReactMethod
openCameranull159   fun openCamera(promise: Promise) {
160     val packageManager = reactApplicationContext.packageManager
161 
162     packageManager.getLaunchIntentForPackage(CLIENT_PACKAGE_NAME)
163       ?.let {
164         tryToDeepLinkIntoQRScannerDirectly(it)
165         promise.resolve(null)
166         return
167       }
168 
169     // try to open the Play Store app...
170     if (openLink(Uri.parse("market://details?id=$CLIENT_PACKAGE_NAME"))) {
171       return
172     }
173 
174     // ...app isn't installed so fallback to the Play Store website
175     if (openLink(Uri.parse("https://play.google.com/store/apps/details?id=$CLIENT_PACKAGE_NAME"))) {
176       return
177     }
178 
179     promise.reject("ERR_DEVELOPMENT_CLIENT_CANNOT_OPEN_CAMERA", "Couldn't find the Expo Go app.")
180   }
181 
182   @ReactMethod
getPendingDeepLinknull183   fun getPendingDeepLink(promise: Promise) {
184     intentRegistry.intent?.data?.let {
185       promise.resolve(it.toString())
186       return
187     }
188 
189     promise.resolve(intentRegistry.intent?.action)
190   }
191 
192   @ReactMethod
getCrashReportnull193   fun getCrashReport(promise: Promise) {
194     val registry = DevLauncherErrorRegistry(reactApplicationContext)
195     promise.resolve(registry.consumeException()?.toWritableMap())
196   }
197 
onNewPendingIntentnull198   private fun onNewPendingIntent(intent: Intent) {
199     intent.data?.toString()?.let {
200       reactApplicationContext
201         .getJSModule(RCTDeviceEventEmitter::class.java)
202         .emit(ON_NEW_DEEP_LINK_EVENT, it)
203     }
204   }
205 
tryToDeepLinkIntoQRScannerDirectlynull206   private fun tryToDeepLinkIntoQRScannerDirectly(fallback: Intent) {
207     if (openLink(CLIENT_HOME_QR_SCANNER_DEEP_LINK)) {
208       return
209     }
210 
211     reactApplicationContext.startActivity(fallback)
212   }
213 
openLinknull214   private fun openLink(uri: Uri): Boolean {
215     return try {
216       reactApplicationContext.startActivity(
217         Intent(Intent.ACTION_VIEW, uri).apply {
218           addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
219         }
220       )
221       true
222     } catch (_: ActivityNotFoundException) {
223       false
224     }
225   }
226 
227   @ReactMethod
getBuildInfonull228   fun getBuildInfo(promise: Promise) {
229     val map = Arguments.createMap()
230     val packageManager = reactApplicationContext.packageManager
231     val packageName = reactApplicationContext.packageName
232 
233     val packageInfo = packageManager.getPackageInfo(packageName, 0)
234     val applicationInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
235     val appName = packageManager.getApplicationLabel(applicationInfo).toString()
236     val runtimeVersion = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_RUNTIME_VERSION")
237     val sdkVersion = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_SDK_VERSION")
238     var appIcon = getApplicationIconUri()
239 
240     var updatesUrl = DevLauncherController.getMetadataValue(reactApplicationContext, "expo.modules.updates.EXPO_UPDATE_URL")
241     var appId = ""
242 
243     if (updatesUrl.isNotEmpty()) {
244       var uri = Uri.parse(updatesUrl)
245       appId = uri.lastPathSegment ?: ""
246     }
247 
248     map.apply {
249       putString("appVersion", packageInfo.versionName)
250       putString("appId", appId)
251       putString("appName", appName)
252       putString("appIcon", appIcon)
253       putString("runtimeVersion", runtimeVersion)
254       putString("sdkVersion", sdkVersion)
255     }
256 
257     promise.resolve(map)
258   }
259 
260   @ReactMethod
copyToClipboardnull261   fun copyToClipboard(content: String, promise: Promise) {
262     val clipboard = reactApplicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
263     val clip = ClipData.newPlainText(null, content)
264     clipboard.setPrimaryClip(clip)
265     promise.resolve(null)
266   }
267 
getApplicationIconUrinull268   private fun getApplicationIconUri(): String {
269     val packageManager = reactApplicationContext.packageManager
270     val packageName = reactApplicationContext.packageName
271     val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
272 //    TODO - figure out how to get resId for AdaptiveIconDrawable icons
273     return "" + applicationInfo.icon
274   }
275 
276   @ReactMethod
loadFontsAsyncnull277   fun loadFontsAsync(promise: Promise) {
278     DevMenuManager.loadFonts(reactApplicationContext)
279     promise.resolve(null)
280   }
281 
282   @ReactMethod
getNavigationStatenull283   fun getNavigationState(promise: Promise) {
284     val sharedPreferences = reactApplicationContext.getSharedPreferences(LAUNCHER_NAVIGATION_STATE_KEY, Context.MODE_PRIVATE)
285     val serializedNavigationState = sharedPreferences.getString("navigationState", null) ?: ""
286     promise.resolve(serializedNavigationState)
287   }
288 
289   @ReactMethod
saveNavigationStatenull290   fun saveNavigationState(serializedNavigationState: String, promise: Promise) {
291     val sharedPreferences = reactApplicationContext.getSharedPreferences(LAUNCHER_NAVIGATION_STATE_KEY, Context.MODE_PRIVATE)
292     sharedPreferences.edit().putString("navigationState", serializedNavigationState).apply()
293     promise.resolve(null)
294   }
295 
296   @ReactMethod
clearNavigationStatenull297   fun clearNavigationState(promise: Promise) {
298     val sharedPreferences = reactApplicationContext.getSharedPreferences(LAUNCHER_NAVIGATION_STATE_KEY, Context.MODE_PRIVATE)
299     sharedPreferences.edit().clear().apply()
300     promise.resolve(null)
301   }
302 }
303