diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ab6774..79ef9dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ The changes documented here do not include those from the original repository. ## 2024-02-28 - Implemented `Open Health Connect App` (https://outsystemsrd.atlassian.net/browse/RMET-3158). +## 2024-02-27 +- Implemented hook for permissions (https://outsystemsrd.atlassian.net/browse/RMET-3142). + ## 2024-02-26 - Implemented `Show app's privacy policy dialog` (https://outsystemsrd.atlassian.net/browse/RMET-3145). diff --git a/hooks/androidCopyPreferencesPermissions.js b/hooks/androidCopyPreferencesPermissions.js new file mode 100644 index 00000000..db84930f --- /dev/null +++ b/hooks/androidCopyPreferencesPermissions.js @@ -0,0 +1,339 @@ +const fs = require('fs'); +const path = require('path'); +const { ConfigParser } = require('cordova-common'); +const { DOMParser, XMLSerializer } = require('xmldom'); + +const READ = "Read" +const WRITE = "Write" +const READWRITE = "ReadWrite" + +let permissions = { + HeartRate: { + variableName: "HeartRate", + readPermission: "android.permission.health.READ_HEART_RATE", + writePermission: "android.permission.health.WRITE_HEART_RATE", + configValue: undefined, + // we'll use these to know if we should write group permissions or not + wasSet: false + }, + Steps: { + variableName: "Steps", + readPermission: "android.permission.health.READ_STEPS", + writePermission: "android.permission.health.WRITE_STEPS", + configValue: undefined, + wasSet: false + }, + Weight: { + variableName: "Weight", + readPermission: "android.permission.health.READ_WEIGHT", + writePermission: "android.permission.health.WRITE_WEIGHT", + configValue: undefined, + wasSet: false + }, + Height: { + variableName: "Height", + readPermission: "android.permission.health.READ_HEIGHT", + writePermission: "android.permission.health.WRITE_HEIGHT", + configValue: undefined, + wasSet: false + }, + CaloriesBurned: { + variableName: "CaloriesBurned", + readPermission: "android.permission.health.READ_TOTAL_CALORIES_BURNED", + writePermission: "android.permission.health.WRITE_TOTAL_CALORIES_BURNED", + configValue: undefined, + wasSet: false + }, + Sleep: { + variableName: "Sleep", + readPermission: "android.permission.health.READ_SLEEP", + writePermission: "android.permission.health.WRITE_SLEEP", + configValue: undefined, + wasSet: false + }, + BloodPressure: { + variableName: "BloodPressure", + readPermission: "android.permission.health.READ_BLOOD_PRESSURE", + writePermission: "android.permission.health.WRITE_BLOOD_PRESSURE", + configValue: undefined, + wasSet: false + }, + BloodGlucose: { + variableName: "BloodGlucose", + readPermission: "android.permission.health.READ_BLOOD_GLUCOSE", + writePermission: "android.permission.health.WRITE_BLOOD_GLUCOSE", + configValue: undefined, + wasSet: false + }, + BodyFatPercentage: { + variableName: "BodyFatPercentage", + readPermission: "android.permission.health.READ_BODY_FAT", + writePermission: "android.permission.health.WRITE_BODY_FAT", + configValue: undefined, + wasSet: false + }, + BasalMetabolicRate: { + variableName: "BasalMetabolicRate", + readPermission: "android.permission.health.READ_BASAL_METABOLIC_RATE", + writePermission: "android.permission.health.WRITE_BASAL_METABOLIC_RATE", + configValue: undefined, + wasSet: false + }, + WalkingSpeed: { + variableName: "WalkingSpeed", + readPermission: "android.permission.health.READ_SPEED", + writePermission: "android.permission.health.WRITE_SPEED", + configValue: undefined, + wasSet: false + }, + Distance: { + variableName: "Distance", + readPermission: "android.permission.health.READ_DISTANCE", + writePermission: "android.permission.health.WRITE_DISTANCE", + configValue: undefined, + wasSet: false + } +} + +let groupPermissions = { + AllVariables: { + variableName: "AllVariables", + configValue: undefined, + wasSet: false, + groupVariables: [] + }, + FitnessVariables: { + variableName: "FitnessVariables", + configValue: undefined, + // we'll use these to know if we should set individual permissions or not + // e.g. when checking HeartRate, if all healthVariables were already set, we don't need to add it again + wasSet: false, + groupVariables: ["Steps", "CaloriesBurned", "WalkingSpeed", "Distance"] + }, + HealthVariables: { + variableName: "HealthVariables", + configValue: undefined, + wasSet: false, + groupVariables: ["HeartRate", "Sleep", "BloodPressure", "BloodGlucose"] + }, + ProfileVariables: { + variableName: "ProfileVariables", + configValue: undefined, + wasSet: false, + groupVariables: ["Weight", "Height", "BodyFatPercentage", "BasalMetabolicRate"] + } +} + +module.exports = async function (context) { + const projectRoot = context.opts.cordova.project ? context.opts.cordova.project.root : context.opts.projectRoot; + const configXML = path.join(projectRoot, 'config.xml'); + const configParser = new ConfigParser(configXML); + const parser = new DOMParser(); + + // add health connect permissions to AndroidManifest.xml and health_permissions.xml files + addHealthConnectPermissionsToXmlFiles(configParser, projectRoot, parser); + + // add background job permissions to AndroidManifest.xml + addBackgroundJobPermissionsToManifest(configParser, projectRoot, parser); + + // copy notification title and content for notificaiton for Foreground Service + copyNotificationContent(configParser, projectRoot, parser); +}; + +function addHealthConnectPermissionsToXmlFiles(configParser, projectRoot, parser) { + + for(const key in permissions){ + permissions[key].configValue = configParser.getPlatformPreference(permissions[key].variableName, 'android'); + } + + for(const key in groupPermissions){ + groupPermissions[key].configValue = configParser.getPlatformPreference(groupPermissions[key].variableName, 'android'); + } + + // Android >= 14 dependencies should be included directly in the AndroidManifest.xml file + // Read the AndroidManifest.xml file + const manifestFilePath = path.join(projectRoot, 'platforms/android/app/src/main/AndroidManifest.xml'); + const manifestXmlString = fs.readFileSync(manifestFilePath, 'utf-8'); + + // Parse the XML string + const manifestXmlDoc = parser.parseFromString(manifestXmlString, 'text/xml'); + + // Android <= 13 dependencies should be included in a separate XML file + // Create the health_permissions.xml file + const permissionsXmlDoc = parser.parseFromString('', 'text/xml'); + + // Get the element + const arrayElement = permissionsXmlDoc.getElementsByTagName('array')[0]; + + // process each individual variable + for(const key in permissions){ + let p = permissions[key] + if (p.configValue == READWRITE || p.configValue == READ) { + p.wasSet = true; + processPermission(manifestXmlDoc, permissionsXmlDoc, arrayElement, p.readPermission) + } + if (p.configValue == READWRITE || p.configValue == WRITE) { + p.wasSet = true; + processPermission(manifestXmlDoc, permissionsXmlDoc, arrayElement, p.writePermission) + } + } + + // process group variables + for(const key in groupPermissions){ + let p = groupPermissions[key] + if (p.configValue == READWRITE || p.configValue == READ) { + p.wasSet = true; + p.groupVariables.forEach( v => { + if (!permissions[v].wasSet) { + processPermission(manifestXmlDoc, permissionsXmlDoc, arrayElement, permissions[v].readPermission) + } + }) + } + if (p.configValue == READWRITE || p.configValue == WRITE) { + p.wasSet = true; + p.groupVariables.forEach( v => { + if (!permissions[v].wasSet) { + processPermission(manifestXmlDoc, permissionsXmlDoc, arrayElement, permissions[v].writePermission) + } + }) + } + } + + let permissionValues = Object.values(permissions) + let groupPermissionValues = Object.values(groupPermissions) + + // process AllVariables + if (groupPermissions.AllVariables.configValue == READWRITE || groupPermissions.AllVariables.configValue == READ) { + processAllVariables(manifestXmlDoc, permissionsXmlDoc, arrayElement, READ, groupPermissionValues) + + } + + if ((groupPermissions.AllVariables.configValue == READWRITE || groupPermissions.AllVariables.configValue == WRITE)) { + processAllVariables(manifestXmlDoc, permissionsXmlDoc, arrayElement, WRITE, groupPermissionValues) + } + + let numberOfPermissions = permissionValues.filter(p => p.configValue != "").length + groupPermissionValues.filter(p => p.configValue != "").length + + // if there is no AllVariables nor anything else, then by default we add all the permissions + if (numberOfPermissions == 0) { + permissionValues.forEach( p => { + processPermission(manifestXmlDoc, permissionsXmlDoc, arrayElement, p.readPermission) + processPermission(manifestXmlDoc, permissionsXmlDoc, arrayElement, p.writePermission) + }) + } + + // Serialize the updated XML document back to string + const serializer = new XMLSerializer(); + + // Android >= 14 + const updatedManifestXmlString = serializer.serializeToString(manifestXmlDoc); + + // Write the updated XML string back to the same file + fs.writeFileSync(manifestFilePath, updatedManifestXmlString, 'utf-8'); + + // Android <= 13 + const updatedPermissionsXmlString = serializer.serializeToString(permissionsXmlDoc); + const permissionsXmlFilePath = path.join(projectRoot, 'platforms/android/app/src/main/res/values/health_permissions.xml'); + + // Write the updated XML string back to the same file + fs.writeFileSync(permissionsXmlFilePath, updatedPermissionsXmlString, 'utf-8'); + +} + +function processAllVariables(manifestXmlDoc, permissionsXmlDoc, arrayElement, permissionOperation, groupPermissionsValues) { + groupPermissionsValues.forEach(p => { + p.groupVariables.forEach( v => { + if (!p.wasSet && !permissions[v].wasSet) { + processPermission(manifestXmlDoc, permissionsXmlDoc, arrayElement, permissionOperation == READ ? permissions[v].readPermission : permissions[v].writePermission) + } + }) + }) +} + +function processPermission(manifestXmlDoc, permissionsXmlDoc, arrayElement, permissionOperation) { + addEntryToManifest(manifestXmlDoc, permissionOperation) + addEntryToPermissionsXML(permissionsXmlDoc, arrayElement, permissionOperation) +} + +function addBackgroundJobPermissionsToManifest(configParser, projectRoot, parser) { + + const disableBackgroundJobs = configParser.getPlatformPreference('DisableBackgroundJobs', 'android'); + + // we want to include the permissions by default + // if disableBackgroundJobs == true then we don't want to include the permissions in the manfiest + if (disableBackgroundJobs !== "true") { + + const manifestFilePath = path.join(projectRoot, 'platforms/android/app/src/main/AndroidManifest.xml'); + const manifestXmlString = fs.readFileSync(manifestFilePath, 'utf-8'); + + // Parse the XML string + const manifestXmlDoc = parser.parseFromString(manifestXmlString, 'text/xml'); + + // add permissions to XML document + addEntryToManifest(manifestXmlDoc, 'android.permission.POST_NOTIFICATIONS') + addEntryToManifest(manifestXmlDoc, 'android.permission.ACTIVITY_RECOGNITION') + addEntryToManifest(manifestXmlDoc, 'android.permission.FOREGROUND_SERVICE') + addEntryToManifest(manifestXmlDoc, 'android.permission.FOREGROUND_SERVICE_HEALTH') + addEntryToManifest(manifestXmlDoc, 'android.permission.HIGH_SAMPLING_RATE_SENSORS') + + // serialize the updated XML document back to string + const serializer = new XMLSerializer(); + const updatedManifestXmlString = serializer.serializeToString(manifestXmlDoc); + + // write the updated XML string back to the same file + fs.writeFileSync(manifestFilePath, updatedManifestXmlString, 'utf-8'); + } + +} + +function addEntryToManifest(manifestXmlDoc, permission) { + const newPermission = manifestXmlDoc.createElement('uses-permission'); + newPermission.setAttribute('android:name', permission); + manifestXmlDoc.documentElement.appendChild(newPermission); +} + +function addEntryToPermissionsXML(permissionsXmlDoc, arrayElement, permission) { + const newItem = permissionsXmlDoc.createElement('item'); + const textNode = permissionsXmlDoc.createTextNode(permission); + newItem.appendChild(textNode); + arrayElement.appendChild(newItem); +} + +function copyNotificationContent(configParser, projectRoot, parser) { + + // get values from config.xml + var notificationTitle = configParser.getPlatformPreference('BackgroundNotificationTitle', 'android'); + var notificationDescription = configParser.getPlatformPreference('BackgroundNotificationDescription', 'android'); + + if (notificationTitle == "") { + notificationTitle = "Measuring your health and fitness data." + } + + if (notificationDescription == "") { + notificationDescription = "The app is running in the background." + } + + // insert values in strings.xml + const stringsXmlPath = path.join(projectRoot, 'platforms/android/app/src/main/res/values/strings.xml'); + const stringsXmlString = fs.readFileSync(stringsXmlPath, 'utf-8'); + const stringsXmlDoc = parser.parseFromString(stringsXmlString, 'text/xml') + const stringElements = stringsXmlDoc.getElementsByTagName('string'); + + // set text for each element + for (let i = 0; i < stringElements.length; i++) { + const name = stringElements[i].getAttribute('name'); + if (name == "background_notification_title") { + stringElements[i].textContent = notificationTitle; + } + else if (name == "background_notification_description") { + stringElements[i].textContent = notificationDescription; + } + } + + // serialize the updated XML document back to string + const serializer = new XMLSerializer(); + const updatedXmlString = serializer.serializeToString(stringsXmlDoc); + + // write the updated XML string back to the same file + fs.writeFileSync(stringsXmlPath, updatedXmlString, 'utf-8'); +} diff --git a/package.json b/package.json index ee9bb79f..88d8b25c 100644 --- a/package.json +++ b/package.json @@ -14,5 +14,8 @@ "ios" ] }, - "engines": [] + "engines": [], + "dependencies": { + "xmldom": "^0.6.0" + } } diff --git a/plugin.xml b/plugin.xml index 471a6cb3..b9dd1a11 100644 --- a/plugin.xml +++ b/plugin.xml @@ -8,6 +8,7 @@ + @@ -22,14 +23,23 @@ PRIVACY_POLICY_URL + + - - - - - - + + + + + + + + diff --git a/src/android/build.gradle b/src/android/build.gradle index 44865c92..3b588568 100644 --- a/src/android/build.gradle +++ b/src/android/build.gradle @@ -27,6 +27,23 @@ dependencies{ implementation("com.github.outsystems:oscordova-android:1.2.0@aar") implementation("com.github.outsystems:oshealthfitness-android:1.2.0.19@aar") implementation("com.github.outsystems:osnotificationpermissions-android:0.0.4@aar") + + // activity + implementation "androidx.activity:activity-ktx:1.8.2" + + // appcompact + implementation "androidx.appcompat:appcompat:1.6.1" + + // health connect sdk + implementation "androidx.health.connect:connect-client:1.1.0-alpha07" + + // work manager + implementation "androidx.work:work-runtime-ktx:2.9.0" + + // compose + implementation 'androidx.activity:activity-compose:1.8.2' + implementation 'androidx.compose.material3:material3:1.2.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' def roomVersion = "2.4.2" implementation("androidx.room:room-runtime:$roomVersion")