Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down Expand Up @@ -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
<!-- res/xml/backup_rules.xml — android:fullBackupContent (Android ≤ 11) -->
<full-backup-content>
<exclude domain="sharedpref" path="motiontag_tracker.xml"/>
</full-backup-content>

<!-- res/xml/data_extraction_rules.xml — android:dataExtractionRules (Android 12+) -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="motiontag_tracker.xml"/>
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="motiontag_tracker.xml"/>
</device-transfer>
</data-extraction-rules>
```

## Pre-RN events

Events that fire between native init (in `MotionTagBootstrap.init` /
Expand Down
2 changes: 2 additions & 0 deletions plugin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -50,6 +51,7 @@ function withMotionTag(config, options) {
[withAndroidManifestExtras],
[withAndroidNotification, merged.androidNotification],
[withAndroidMainApplication],
[withAndroidBackupRules],
])
}

Expand Down
264 changes: 264 additions & 0 deletions plugin/withAndroidBackupRules.js
Original file line number Diff line number Diff line change
@@ -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:
* - `<full-backup-content>` (Android <= 11, android:fullBackupContent)
* - `<data-extraction-rules>` (Android 12+, android:dataExtractionRules) —
* the exclude goes into both `<cloud-backup>` and `<device-transfer>`.
*/
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/<name>` 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 <exclude domain="sharedpref" path="${SDK_SHAREDPREF_FILE}"/> 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 <exclude domain="sharedpref" path="${SDK_SHAREDPREF_FILE}"/> 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
},
])
}
Loading