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