1 package abi47_0_0.host.exp.exponent.modules.universal
2 
3 import android.content.Context
4 import android.util.Base64
5 import com.google.firebase.FirebaseApp
6 import com.google.firebase.FirebaseOptions
7 import abi47_0_0.expo.modules.core.ModuleRegistry
8 import abi47_0_0.expo.modules.core.interfaces.RegistryLifecycleListener
9 import abi47_0_0.expo.modules.firebase.core.FirebaseCoreOptions
10 import abi47_0_0.expo.modules.firebase.core.FirebaseCoreService
11 import expo.modules.manifests.core.Manifest
12 import host.exp.exponent.kernel.ExperienceKey
13 import org.json.JSONObject
14 import java.io.UnsupportedEncodingException
15 import java.lang.Exception
16 
17 class ScopedFirebaseCoreService(
18   context: Context,
19   manifest: Manifest,
20   experienceKey: ExperienceKey
21 ) : FirebaseCoreService(context), RegistryLifecycleListener {
22   private val appName: String
23   private val appOptions: FirebaseOptions?
24 
getAppNamenull25   override fun getAppName(): String {
26     return appName
27   }
28 
getAppOptionsnull29   override fun getAppOptions(): FirebaseOptions? {
30     return appOptions
31   }
32 
isAppAccessiblenull33   override fun isAppAccessible(name: String): Boolean {
34     synchronized(protectedAppNames) {
35       if (protectedAppNames.containsKey(name) && appName != name) {
36         return false
37       }
38     }
39     return super.isAppAccessible(name)
40   }
41 
42   // Registry lifecycle events
onCreatenull43   override fun onCreate(moduleRegistry: ModuleRegistry) {
44     // noop
45   }
46 
onDestroynull47   override fun onDestroy() {
48     // Mark this Firebase App as deleted. Don't delete it straight
49     // away, but mark it for deletion. When loading a new project
50     // a check is performed that will cleanup the deleted Firebase apps.
51     // This ensures that Firebase Apps don't get deleted/recreated
52     // every time a project reload happens, and also also ensures that
53     // `isAppAccessible` keeps the app unavailable for other project/packages
54     // after unload.
55     synchronized(protectedAppNames) { protectedAppNames.put(appName, true) }
56   }
57 
58   companion object {
59     private val protectedAppNames = mutableMapOf<String, Boolean>() // Map<App-name, isDeleted>
60 
getEncodedExperienceScopeKeynull61     private fun getEncodedExperienceScopeKey(experienceKey: ExperienceKey): String {
62       return try {
63         val encodedUrl = experienceKey.getUrlEncodedScopeKey()
64         val data = encodedUrl.toByteArray(charset("UTF-8"))
65         Base64.encodeToString(
66           data,
67           Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
68         )
69       } catch (e: UnsupportedEncodingException) {
70         experienceKey.scopeKey.hashCode().toString()
71       }
72     }
73 
74     // google-services.json loading
75 
getJSONStringByPathnull76     private fun getJSONStringByPath(jsonArg: JSONObject?, path: String): String? {
77       var json = jsonArg ?: return null
78       return try {
79         val paths = path.split(".").toTypedArray()
80         for (i in paths.indices) {
81           val name = paths[i]
82           if (!json.has(name)) return null
83           if (i == paths.size - 1) {
84             return json.getString(name)
85           } else {
86             json = json.getJSONObject(name)
87           }
88         }
89         null
90       } catch (err: Exception) {
91         null
92       }
93     }
94 
putJSONStringnull95     private fun MutableMap<String, String>.putJSONString(
96       key: String,
97       json: JSONObject?,
98       path: String
99     ) {
100       val value = getJSONStringByPath(json, path)
101       if (value != null) this[key] = value
102     }
103 
getClientFromGoogleServicesnull104     private fun getClientFromGoogleServices(
105       googleServicesFile: JSONObject?,
106       preferredPackageNames: List<String?>
107     ): JSONObject? {
108       val clients = googleServicesFile?.optJSONArray("client") ?: return null
109 
110       // Find the client and prefer the ones that are in the preferredPackageNames list.
111       // Later in the list means higher priority.
112       var client: JSONObject? = null
113       var clientPreferredPackageNameIndex = -1
114       for (i in 0 until clients.length()) {
115         val possibleClient = clients.optJSONObject(i)
116         if (possibleClient != null) {
117           val packageName = getJSONStringByPath(possibleClient, "client_info.android_client_info.package_name")
118           val preferredPackageNameIndex = if (packageName != null) preferredPackageNames.indexOf(packageName) else -1
119           if (client == null || preferredPackageNameIndex > clientPreferredPackageNameIndex) {
120             client = possibleClient
121             clientPreferredPackageNameIndex = preferredPackageNameIndex
122           }
123         }
124       }
125       return client
126     }
127 
getOptionsFromManifestnull128     private fun getOptionsFromManifest(manifest: Manifest): FirebaseOptions? {
129       return try {
130         val googleServicesFileString = manifest.getAndroidGoogleServicesFile()
131         val googleServicesFile = if (googleServicesFileString != null) JSONObject(googleServicesFileString) else null
132         val packageName = if (manifest.getAndroidPackageName() != null) manifest.getAndroidPackageName() else ""
133 
134         // Read project-info settings
135         // https://developers.google.com/android/guides/google-services-plugin
136         val json = mutableMapOf<String, String>().apply {
137           putJSONString("projectId", googleServicesFile, "project_info.project_id")
138           putJSONString("messagingSenderId", googleServicesFile, "project_info.project_number")
139           putJSONString("databaseURL", googleServicesFile, "project_info.firebase_url")
140           putJSONString("storageBucket", googleServicesFile, "project_info.storage_bucket")
141         }
142 
143         // Get the client that matches this app. When the Expo Go package was explicitly
144         // configured in google-services.json, then use that app when possible.
145         // Otherwise, use the client that matches the package_name specified in app.json.
146         // If none of those are found, use first encountered client in google-services.json.
147         val client = getClientFromGoogleServices(
148           googleServicesFile,
149           listOf(
150             packageName,
151             "host.exp.exponent"
152           )
153         )
154 
155         // Read properties from client
156         json.putJSONString("appId", client, "client_info.mobilesdk_app_id")
157         json.putJSONString(
158           "trackingId",
159           client,
160           "services.analytics_service.analytics_property.tracking_id"
161         )
162         val apiKey = client?.optJSONArray("api_key")
163         if (apiKey != null && apiKey.length() > 0) {
164           json.putJSONString("apiKey", apiKey.getJSONObject(0), "current_key")
165         }
166 
167         // The appId is the best indicator on whether all required info was available
168         // and parsed correctly.
169         if (json.containsKey("appId")) FirebaseCoreOptions.fromJSON(json) else null
170       } catch (err: Exception) {
171         null
172       }
173     }
174   }
175 
176   init {
177     // Get the default firebase app name
178     val defaultApp = getFirebaseApp(null)
179     val defaultAppName = defaultApp?.name ?: DEFAULT_APP_NAME
180 
181     // Get experience key & unique app name
182     appName = "__sandbox_" + getEncodedExperienceScopeKey(experienceKey)
183     appOptions = getOptionsFromManifest(manifest)
184 
185     // Add the app to the list of protected app names
<lambda>null186     synchronized(protectedAppNames) {
187       protectedAppNames[defaultAppName] = false
188       protectedAppNames[appName] = false
189     }
190 
191     // Delete any previously created apps for which the project was unloaded
192     // This ensures that the list of Firebase Apps doesn't keep growing
193     // for each uniquely loaded project.
appnull194     for (app in FirebaseApp.getApps(context)) {
195       var isDeleted = false
196       synchronized(protectedAppNames) {
197         if (protectedAppNames.containsKey(app.name)) {
198           isDeleted = protectedAppNames[app.name]!!
199         }
200       }
201       if (isDeleted) {
202         app.delete()
203       }
204     }
205 
206     // Cleanup any deleted apps from the protected-names map
<lambda>null207     synchronized(protectedAppNames) {
208       val forRemoval = mutableSetOf<String>()
209       for ((key, value) in protectedAppNames) {
210         if (value) { // isDeleted
211           forRemoval.add(key)
212         }
213       }
214       for (app in forRemoval) {
215         protectedAppNames.remove(app)
216       }
217     }
218 
219     // Initialize the firebase app. This will delete/create/update the app
220     // if it has changed, and leaves the app untouched when the config
221     // is the same.
222     updateFirebaseApp(appOptions, appName)
223   }
224 }
225