diff --git a/README.md b/README.md index 4c10f3b..b823196 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,10 @@ The package ships an Expo config plugin that wires up everything for you on `expo prebuild`: AppDelegate bootstrap + background URL session forwarding (iOS), MainApplication bootstrap (Android), `Info.plist` permission + background-mode keys, foreground-service notification factory, the Azure -DevOps Maven repo, and the extra Android permissions (`POST_NOTIFICATIONS`, -`FOREGROUND_SERVICE`). +DevOps Maven repo, the extra Android permissions (`POST_NOTIFICATIONS`, +`FOREGROUND_SERVICE`), and Android Auto Backup rules excluding the SDK's +state file (merged into existing backup rules, e.g. expo-secure-store's, +when present). Add the plugin to `app.json`: @@ -182,6 +184,28 @@ SDK permissions (`ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`, `ACCESS_BACKGROUND_LOCATION`, `ACTIVITY_RECOGNITION`, `FOREGROUND_SERVICE_LOCATION`) are merged into the host manifest by Gradle. +The SDK stores its state in the `motiontag_tracker` SharedPreferences file, +which must be excluded from Android Auto Backup — restored backups would +otherwise resurrect stale SDK state after a reinstall. Unless the app sets +`android:allowBackup="false"`, exclude it in both rule formats: + +```xml + + + + + + + + + + + + + + +``` + ## Pre-RN events Events that fire between native init (in `MotionTagBootstrap.init` / diff --git a/plugin/index.js b/plugin/index.js index cee1751..3f5cb1a 100644 --- a/plugin/index.js +++ b/plugin/index.js @@ -6,6 +6,7 @@ const withAndroidMavenRepo = require('./withAndroidMavenRepo') const withAndroidManifestExtras = require('./withAndroidManifestExtras') const withAndroidNotification = require('./withAndroidNotification') const withAndroidMainApplication = require('./withAndroidMainApplication') +const withAndroidBackupRules = require('./withAndroidBackupRules') const DEFAULTS = { iosPermissions: { @@ -50,6 +51,7 @@ function withMotionTag(config, options) { [withAndroidManifestExtras], [withAndroidNotification, merged.androidNotification], [withAndroidMainApplication], + [withAndroidBackupRules], ]) } diff --git a/plugin/withAndroidBackupRules.js b/plugin/withAndroidBackupRules.js new file mode 100644 index 0000000..5b81733 --- /dev/null +++ b/plugin/withAndroidBackupRules.js @@ -0,0 +1,264 @@ +const { withFinalizedMod, AndroidConfig, XML } = require('@expo/config-plugins') +const fs = require('fs') +const path = require('path') + +// The Android SDK stores its state (device registration, tracking state) in a +// SharedPreferences file named `motiontag_tracker` (verified against the +// de.motiontag:tracker AAR). The MotionTag guide requires excluding it from +// Android Auto Backup: a backup restored on reinstall / new device would +// resurrect stale SDK state. +const SDK_SHAREDPREF_FILE = 'motiontag_tracker.xml' + +const OWN_BACKUP_RULES_NAME = 'motiontag_backup_rules' +const OWN_EXTRACTION_RULES_NAME = 'motiontag_data_extraction_rules' + +const WARN_PREFIX = '[react-native-motiontag]' + +function excludeEntry() { + return { $: { domain: 'sharedpref', path: SDK_SHAREDPREF_FILE } } +} + +function hasExclude(node) { + return (node.exclude || []).some( + (e) => + e && + e.$ && + e.$.domain === 'sharedpref' && + e.$.path === SDK_SHAREDPREF_FILE, + ) +} + +function addExclude(node) { + if (hasExclude(node)) { + return false + } + node.exclude = [...(node.exclude || []), excludeEntry()] + return true +} + +/** + * Add the MotionTag exclude to a parsed backup-rules document. Returns true + * when the document was changed. Handles both rule formats: + * - `` (Android <= 11, android:fullBackupContent) + * - `` (Android 12+, android:dataExtractionRules) — + * the exclude goes into both `` and ``. + */ +function mergeExcludeIntoRules(doc) { + if (doc['full-backup-content']) { + return addExclude(doc['full-backup-content']) + } + if (doc['data-extraction-rules']) { + const root = doc['data-extraction-rules'] + let changed = false + for (const section of ['cloud-backup', 'device-transfer']) { + if (!root[section]) { + root[section] = [{}] + } + for (const node of root[section]) { + changed = addExclude(node) || changed + } + } + return changed + } + return false +} + +/** + * Resolve a `@xml/` manifest reference to an XML file on disk. + * App resources win over library resources (mirrors Android resource merging), + * so look in the app first, then in node_modules (walking up for hoisted + * monorepo layouts). + */ +function findRulesXml(name, { appResXmlDir, projectRoot }) { + const appFile = path.join(appResXmlDir, `${name}.xml`) + if (fs.existsSync(appFile)) { + return appFile + } + + let dir = projectRoot + for (let depth = 0; depth < 5; depth++) { + const nodeModules = path.join(dir, 'node_modules') + if (fs.existsSync(nodeModules)) { + const match = findInNodeModules(nodeModules, `${name}.xml`) + if (match) { + return match + } + } + const parent = path.dirname(dir) + if (parent === dir) { + break + } + dir = parent + } + return null +} + +function findInNodeModules(nodeModules, fileName) { + for (const entry of fs.readdirSync(nodeModules)) { + if (entry.startsWith('.')) { + continue + } + const pkgDirs = entry.startsWith('@') + ? fs + .readdirSync(path.join(nodeModules, entry)) + .map((scoped) => path.join(nodeModules, entry, scoped)) + : [path.join(nodeModules, entry)] + for (const pkgDir of pkgDirs) { + const candidate = path.join( + pkgDir, + 'android', + 'src', + 'main', + 'res', + 'xml', + fileName, + ) + if (fs.existsSync(candidate)) { + return candidate + } + } + } + return null +} + +function emptyRulesDoc(manifestAttr) { + // Exclude-only rules: Android backs up everything except the listed paths, + // which preserves the host's default backup behaviour. + if (manifestAttr === 'android:fullBackupContent') { + return { 'full-backup-content': {} } + } + return { + 'data-extraction-rules': { + 'cloud-backup': [{}], + 'device-transfer': [{}], + }, + } +} + +/** + * Ensure one of the two backup-rule manifest attributes excludes the MotionTag + * SharedPreferences file. + */ +async function ensureRulesFor(manifestAttr, ownName, ctx) { + const { mainApplication, appResXmlDir, projectRoot } = ctx + const value = mainApplication.$[manifestAttr] + + // "false" disables this backup mechanism entirely — nothing to exclude. + if (value === 'false') { + return false + } + + if (!value || value === 'true') { + // No rules yet: create MotionTag-owned exclude-only rules. + const doc = emptyRulesDoc(manifestAttr) + mergeExcludeIntoRules(doc) + fs.mkdirSync(appResXmlDir, { recursive: true }) + await XML.writeXMLAsync({ + path: path.join(appResXmlDir, `${ownName}.xml`), + xml: doc, + }) + mainApplication.$[manifestAttr] = `@xml/${ownName}` + return true + } + + const resourceName = value.startsWith('@xml/') ? value.slice('@xml/'.length) : null + if (!resourceName) { + console.warn( + `${WARN_PREFIX} ${manifestAttr} is set to "${value}", which this plugin cannot edit. ` + + `Add to your backup rules manually ` + + '(required by the MotionTag SDK).', + ) + return false + } + + // Existing rules (host-owned or from another library, e.g. expo-secure-store): + // merge our exclude in and write the result as an app resource. An app + // resource with the same name overrides a library resource, so the manifest + // reference keeps working and other plugins still recognise their own value. + const sourceFile = findRulesXml(resourceName, { appResXmlDir, projectRoot }) + if (!sourceFile) { + console.warn( + `${WARN_PREFIX} ${manifestAttr} references @xml/${resourceName}, but ${resourceName}.xml was not found ` + + `in the app or node_modules. Add to it manually ` + + '(required by the MotionTag SDK).', + ) + return false + } + + const doc = await XML.readXMLAsync({ path: sourceFile }) + if (!mergeExcludeIntoRules(doc)) { + // Already excluded (re-run) and the file is already where Android expects it. + if (path.dirname(sourceFile) === appResXmlDir) { + return false + } + } + fs.mkdirSync(appResXmlDir, { recursive: true }) + await XML.writeXMLAsync({ + path: path.join(appResXmlDir, `${resourceName}.xml`), + xml: doc, + }) + return false // manifest unchanged — only the resource file was written +} + +/** + * Exclude the MotionTag SDK's SharedPreferences from Android Auto Backup. + * + * Runs as a finalized mod so it sees the manifest *after* every other plugin + * (e.g. expo-secure-store) has applied its own backup configuration, + * regardless of plugin ordering in app.json. + */ +module.exports = function withAndroidBackupRules(config) { + return withFinalizedMod(config, [ + 'android', + async (cfg) => { + const projectRoot = cfg.modRequest.projectRoot + const platformRoot = cfg.modRequest.platformProjectRoot + const manifestPath = path.join( + platformRoot, + 'app', + 'src', + 'main', + 'AndroidManifest.xml', + ) + const appResXmlDir = path.join( + platformRoot, + 'app', + 'src', + 'main', + 'res', + 'xml', + ) + + const manifest = await AndroidConfig.Manifest.readAndroidManifestAsync( + manifestPath, + ) + const mainApplication = + AndroidConfig.Manifest.getMainApplicationOrThrow(manifest) + + // Auto Backup disabled entirely — no stale-state risk, nothing to do. + if (mainApplication.$['android:allowBackup'] === 'false') { + return cfg + } + + const ctx = { mainApplication, appResXmlDir, projectRoot } + const changedFullBackup = await ensureRulesFor( + 'android:fullBackupContent', + OWN_BACKUP_RULES_NAME, + ctx, + ) + const changedExtraction = await ensureRulesFor( + 'android:dataExtractionRules', + OWN_EXTRACTION_RULES_NAME, + ctx, + ) + + if (changedFullBackup || changedExtraction) { + await AndroidConfig.Manifest.writeAndroidManifestAsync( + manifestPath, + manifest, + ) + } + return cfg + }, + ]) +}