react-native-health-connect icon indicating copy to clipboard operation
react-native-health-connect copied to clipboard

lateinit property requestPermission has not been initialized

Open mohamadfala opened this issue 1 year ago • 7 comments

Describe the bug I've followed the exact installation in your documentation on how to use it with expo. I'm using an expo dev build with config plugin.

Tried to run the app on Android 13 and 10, both encountered the same error. Downgraded to version 1.2.3 and it is now working.

However, I'd like to report the issue so I can upgrade to the latest version.

Here's the error log:

Your app just crashed. See the error below. kotlin.UninitializedPropertyAccessException: lateinit property requestPermission has not been initialized dev.matinzd.healthconnect.permissions.HealthConnectPermissionDelegate.launch(HealthConnectPermissionDelegate.kt:32) dev.matinzd.healthconnect.HealthConnectManager$requestPermission$1$1.invokeSuspend(HealthConnectManager.kt:64) kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108) kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:115) kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:103) kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584) kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793) kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697) kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)

Screenshot 2024-03-06 at 7 09 17 PM

Environment:

  • Health Connect Version: 2.0.1
  • React Native Version: 0.73.5

mohamadfala avatar Mar 06 '24 17:03 mohamadfala

+1

abhaynpai avatar Mar 08 '24 03:03 abhaynpai

I confirm the bug too

xseignard avatar Mar 09 '24 07:03 xseignard

This is not a bug. You have to do additional configuration in your MainActivity file which is not available with Expo. Refer to the release details for more info:

https://github.com/matinzd/react-native-health-connect/releases/tag/v2.0.0

Try to rebuild your Android folder with dev client and add these configurations manually to MainActivity. Or you can write your own ezpo config plugin.

matinzd avatar Mar 09 '24 07:03 matinzd

Hey @matinzd , indeed

I created my own custom expo config plugin for that inspired by https://github.com/matinzd/react-native-health-connect/pull/70:

import { ConfigPlugin, withAndroidManifest, withDangerousMod, withMainActivity } from "@expo/config-plugins";
import path from "node:path";
import { promises as fs } from "node:fs";

type InsertProps = {
  /** The content to look at */
  content: string;
  /** The string to find within `content` */
  toFind: string;
  /** What to insert in the `content`, be it a single string, or an array of string that would be separated by a `\n` */
  toInsert: string | string[];
  /** A tag that will be used to keep track of which `expo-plugin-config` introduced the modification */
  tag: string;
  /** The symbol(s) to be used to begin a comment in the given `content`. If an array, the first item will be used to start the comment, the second to end it */
  commentSymbol: string | [string, string];
};

const createCommentSymbols = (commentSymbol: InsertProps["commentSymbol"]) => {
  return {
    startCommentSymbol: Array.isArray(commentSymbol) ? commentSymbol[0] : commentSymbol,
    endCommentSymbol: Array.isArray(commentSymbol) ? ` ${commentSymbol[1]}` : "",
  };
};

const createStartTag = (
  commentSymbol: InsertProps["commentSymbol"],
  tag: InsertProps["tag"],
  toInsert: InsertProps["toInsert"],
) => {
  const { startCommentSymbol, endCommentSymbol } = createCommentSymbols(commentSymbol);
  return `${startCommentSymbol} @generated begin ${tag} - expo prebuild (DO NOT MODIFY)${endCommentSymbol}`;
};

const createEndTag = (commentSymbol: InsertProps["commentSymbol"], tag: InsertProps["tag"]) => {
  const { startCommentSymbol, endCommentSymbol } = createCommentSymbols(commentSymbol);
  return `${startCommentSymbol} @generated end ${tag}${endCommentSymbol}`;
};

const createContentToInsert = (
  commentSymbol: InsertProps["commentSymbol"],
  tag: InsertProps["tag"],
  toInsert: InsertProps["toInsert"],
) => {
  const startTag = createStartTag(commentSymbol, tag, toInsert);
  const endTag = createEndTag(commentSymbol, tag);
  return `${startTag}\n${Array.isArray(toInsert) ? toInsert.join("\n") : toInsert}\n${endTag}`;
};

const insert = ({
  content,
  toFind,
  toInsert,
  tag,
  commentSymbol,
  where,
}: InsertProps & {
  where: "before" | "after" | "replace";
}): string => {
  const toInsertWithComments = createContentToInsert(commentSymbol, tag, toInsert);
  if (!content.includes(toFind)) {
    throw new Error(`Couldn't find ${toFind} in the given props.content`);
  }
  if (!content.includes(toInsertWithComments)) {
    switch (where) {
      case "before":
        content = content.replace(toFind, `${toInsertWithComments}\n${toFind}`);
        break;
      case "after":
        content = content.replace(toFind, `${toFind}\n${toInsertWithComments}`);
        break;
      case "replace":
        content = content.replace(toFind, `${toInsertWithComments}`);
        break;
    }
  }
  return content;
};

/**
 * Insert `props.toInsert` into `props.content` the line after `props.toFind`
 * @returns the modified `props.content`
 */
export const insertAfter = (props: InsertProps) => {
  return insert({ ...props, where: "after" });
};

/**
 * Insert `props.toInsert` into `props.content` the line before `props.toFind`
 * @returns the modified `props.content`
 */
export const insertBefore = (props: InsertProps) => {
  return insert({ ...props, where: "before" });
};

/**
 * Replace `props.toFind` by `props.toInsert` into `props.content`
 * @returns the modified `props.content`
 */
export const replace = (props: InsertProps) => {
  return insert({ ...props, where: "replace" });
};

/** Copies `srcFile` to `destFolder` with an optional `destFileName` or its initial name if not provided
 * @returns the path of the created file
 */
const copyFile = async (srcFile: string, destFolder: string, destFileName?: string) => {
  const fileName = destFileName ?? path.basename(srcFile);
  await fs.mkdir(destFolder, { recursive: true });
  const destFile = path.resolve(destFolder, fileName);
  await fs.copyFile(srcFile, destFile);
  return destFile;
};

const withReactNativeHealthConnect: ConfigPlugin<{
  permissionsRationaleActivityPath: string;
}> = (config, { permissionsRationaleActivityPath }) => {
  config = withAndroidManifest(config, async (config) => {
    const androidManifest = config.modResults.manifest;
    if (!androidManifest?.application?.[0]) {
      throw new Error("AndroidManifest.xml is not valid!");
    }
    if (!androidManifest.application[0]["activity"]) {
      throw new Error("AndroidManifest.xml is missing application activity");
    }
    androidManifest.application[0]["activity"].push({
      $: {
        "android:name": ".PermissionsRationaleActivity",
        "android:exported": "true",
      },
      "intent-filter": [
        {
          action: [{ $: { "android:name": "androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" } }],
        },
      ],
    });
    // @ts-expect-error activity-alias is not defined in the type
    if (!androidManifest.application[0]["activity-alias"]) {
      // @ts-expect-error activity-alias is not defined in the type
      androidManifest.application[0]["activity-alias"] = [];
    }
    // @ts-expect-error activity-alias is not defined in the type
    androidManifest.application[0]["activity-alias"].push({
      $: {
        "android:name": "ViewPermissionUsageActivity",
        "android:exported": "true",
        "android:targetActivity": ".PermissionsRationaleActivity",
        "android:permission": "android.permission.START_VIEW_PERMISSION_USAGE",
      },
      "intent-filter": [
        {
          action: [{ $: { "android:name": "android.intent.action.VIEW_PERMISSION_USAGE" } }],
          category: [{ $: { "android:name": "android.intent.category.HEALTH_PERMISSIONS" } }],
        },
      ],
    });

    return config;
  });
 
  config = withMainActivity(config, async (config) => {
    config.modResults.contents = insertAfter({
      content: config.modResults.contents,
      toFind: "import com.facebook.react.defaults.DefaultReactActivityDelegate;",
      toInsert: "import dev.matinzd.healthconnect.permissions.HealthConnectPermissionDelegate;",
      commentSymbol: "//",
      tag: "withReactNativeHealthConnect",
    });
    config.modResults.contents = insertAfter({
      content: config.modResults.contents,
      toFind: "super.onCreate(null);",
      toInsert: [
        "HealthConnectPermissionDelegate hcpd = HealthConnectPermissionDelegate.INSTANCE;",
        'hcpd.setPermissionDelegate(this, "com.google.android.apps.healthdata");',
      ],
      commentSymbol: "//",
      tag: "withReactNativeHealthConnect",
    });
    return config;
  });

  config = withDangerousMod(config, [
    "android",
    async (config) => {
      const projectRoot = config.modRequest.projectRoot;
      const destPath = path.resolve(projectRoot, "android/app/src/main/java/com/alanmobile");
      await copyFile(permissionsRationaleActivityPath, destPath, "PermissionsRationaleActivity.kt");
      return config;
    },
  ]);

  return config;
};

export default withReactNativeHealthConnect;

Should be straightforward to update the expo config plugin in this repo from this one

xseignard avatar Mar 09 '24 08:03 xseignard

Does this plugin still fix the issue ? I copied it at the root of my project, then ran npx tsc androidManifestPlugin.ts --skipLibCheck. And add the compiled file inside my app.json plugin array.

However, when it comes to build the app, I caught this error :

Cannot read properties of undefined (reading 'permissionsRationaleActivityPath')

alexandre-kakal avatar Mar 13 '24 22:03 alexandre-kakal

My MainActivity is written in Kotlin but not Java, so I've customized the plugin.

// https://github.com/matinzd/react-native-health-connect/issues/71#issuecomment-1986791229

import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { ConfigPlugin, withAndroidManifest, withDangerousMod, withMainActivity } from '@expo/config-plugins';

type InsertProps = {
  /** The content to look at */
  content: string;
  /** The string to find within `content` */
  toFind: string;
  /** What to insert in the `content`, be it a single string, or an array of string that would be separated by a `\n` */
  toInsert: string | string[];
  /** A tag that will be used to keep track of which `expo-plugin-config` introduced the modification */
  tag: string;
  /** The symbol(s) to be used to begin a comment in the given `content`. If an array, the first item will be used to start the comment, the second to end it */
  commentSymbol: string | [string, string];
};

const createCommentSymbols = (commentSymbol: InsertProps['commentSymbol']) => {
  return {
    startCommentSymbol: Array.isArray(commentSymbol) ? commentSymbol[0] : commentSymbol,
    endCommentSymbol: Array.isArray(commentSymbol) ? ` ${commentSymbol[1]}` : '',
  };
};

const createStartTag = (
  commentSymbol: InsertProps['commentSymbol'],
  tag: InsertProps['tag'],
  _: InsertProps['toInsert'],
) => {
  const { startCommentSymbol, endCommentSymbol } = createCommentSymbols(commentSymbol);
  return `${startCommentSymbol} @generated begin ${tag} - expo prebuild (DO NOT MODIFY)${endCommentSymbol}`;
};

const createEndTag = (commentSymbol: InsertProps['commentSymbol'], tag: InsertProps['tag']) => {
  const { startCommentSymbol, endCommentSymbol } = createCommentSymbols(commentSymbol);
  return `${startCommentSymbol} @generated end ${tag}${endCommentSymbol}`;
};

const createContentToInsert = (
  commentSymbol: InsertProps['commentSymbol'],
  tag: InsertProps['tag'],
  toInsert: InsertProps['toInsert'],
) => {
  const startTag = createStartTag(commentSymbol, tag, toInsert);
  const endTag = createEndTag(commentSymbol, tag);
  return `${startTag}\n${Array.isArray(toInsert) ? toInsert.join('\n') : toInsert}\n${endTag}`;
};

const insert = ({
  content,
  toFind,
  toInsert,
  tag,
  commentSymbol,
  where,
}: InsertProps & {
  where: 'before' | 'after' | 'replace';
}): string => {
  const toInsertWithComments = createContentToInsert(commentSymbol, tag, toInsert);
  if (!content.includes(toFind)) {
    throw new Error(`Couldn't find ${toFind} in the given props.content`);
  }
  if (!content.includes(toInsertWithComments)) {
    switch (where) {
      case 'before':
        content = content.replace(toFind, `${toInsertWithComments}\n${toFind}`);
        break;
      case 'after':
        content = content.replace(toFind, `${toFind}\n${toInsertWithComments}`);
        break;
      case 'replace':
        content = content.replace(toFind, `${toInsertWithComments}`);
        break;
    }
  }
  return content;
};

/**
 * Insert `props.toInsert` into `props.content` the line after `props.toFind`
 * @returns the modified `props.content`
 */
export const insertAfter = (props: InsertProps) => {
  return insert({ ...props, where: 'after' });
};

/**
 * Insert `props.toInsert` into `props.content` the line before `props.toFind`
 * @returns the modified `props.content`
 */
export const insertBefore = (props: InsertProps) => {
  return insert({ ...props, where: 'before' });
};

/**
 * Replace `props.toFind` by `props.toInsert` into `props.content`
 * @returns the modified `props.content`
 */
export const replace = (props: InsertProps) => {
  return insert({ ...props, where: 'replace' });
};

/** Copies `srcFile` to `destFolder` with an optional `destFileName` or its initial name if not provided
 * @returns the path of the created file
 */
const copyFile = async (srcFile: string, destFolder: string, packageName: string) => {
  const fileName = path.basename(srcFile);
  await fs.mkdir(destFolder, { recursive: true });
  const destFile = path.resolve(destFolder, fileName);
  const buf = await fs.readFile(srcFile);
  const content = buf.toString().replace('{pkg}', packageName);
  await fs.writeFile(destFile, content);
  return destFile;
};

const withReactNativeHealthConnect: ConfigPlugin = (config) => {
  config = withAndroidManifest(config, async (config) => {
    const androidManifest = config.modResults.manifest;
    if (!androidManifest?.application?.[0]) {
      throw new Error('AndroidManifest.xml is not valid!');
    }
    if (!androidManifest.application[0]['activity']) {
      throw new Error('AndroidManifest.xml is missing application activity');
    }

    // for Android 13
    androidManifest.application[0]['activity'].push({
      $: {
        'android:name': '.PermissionsRationaleActivity',
        'android:exported': 'true',
      },
      'intent-filter': [
        {
          action: [{ $: { 'android:name': 'androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE' } }],
        },
      ],
    });

    // for Android 14
    // @ts-expect-error activity-alias is not defined in the type
    if (!androidManifest.application[0]['activity-alias']) {
      // @ts-expect-error activity-alias is not defined in the type
      androidManifest.application[0]['activity-alias'] = [];
    }
    // @ts-expect-error activity-alias is not defined in the type
    androidManifest.application[0]['activity-alias'].push({
      $: {
        'android:name': 'ViewPermissionUsageActivity',
        'android:exported': 'true',
        'android:targetActivity': '.PermissionsRationaleActivity',
        'android:permission': 'android.permission.START_VIEW_PERMISSION_USAGE',
      },
      'intent-filter': [
        {
          action: [{ $: { 'android:name': 'android.intent.action.VIEW_PERMISSION_USAGE' } }],
          category: [{ $: { 'android:name': 'android.intent.category.HEALTH_PERMISSIONS' } }],
        },
      ],
    });

    return config;
  });

  config = withMainActivity(config, async (config) => {
    config.modResults.contents = insertAfter({
      content: config.modResults.contents,
      toFind: 'import com.facebook.react.defaults.DefaultReactActivityDelegate',
      toInsert: 'import dev.matinzd.healthconnect.permissions.HealthConnectPermissionDelegate',
      commentSymbol: '//',
      tag: 'withReactNativeHealthConnect',
    });
    config.modResults.contents = replace({
      content: config.modResults.contents,
      toFind: 'super.onCreate(null)',
      toInsert: ['super.onCreate(savedInstanceState)', 'HealthConnectPermissionDelegate.setPermissionDelegate(this)'],
      commentSymbol: '//',
      tag: 'withReactNativeHealthConnect',
    });
    return config;
  });

  config = withDangerousMod(config, [
    'android',
    async (config) => {
      const pkg = config.android?.package;
      if (!pkg) {
        throw new Error('no package id');
      }
      const projectRoot = config.modRequest.projectRoot;
      const destPath = path.resolve(projectRoot, `android/app/src/main/java/${pkg.split('.').join('/')}`);
      await copyFile(__dirname + '/PermissionRationaleActivity.kt', destPath, pkg);
      return config;
    },
  ]);

  return config;
};

// eslint-disable-next-line import/no-default-export
export default withReactNativeHealthConnect;

Put PermissionRationaleActivity.kt next to the plugin file to work fine. {pkg} will be automatically replaced by the plugin.

package {pkg}

import android.os.Bundle
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity

class PermissionsRationaleActivity: AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val webView = WebView(this)
    webView.webViewClient = object : WebViewClient() {
      override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
        return false
      }
    }

    webView.loadUrl("https://developer.android.com/health-and-fitness/guides/health-connect/develop/get-started")

    setContentView(webView)
  }
}

yukukotani avatar Mar 18 '24 14:03 yukukotani

Side note here:

Make sure to customise the rationale activity based on your need. That file is just an example webview and you need to create your own native or js view.

matinzd avatar Mar 18 '24 18:03 matinzd

Can someone try this new expo adapter and let me know if it works properly for you?

https://github.com/matinzd/expo-health-connect

matinzd avatar Jun 29 '24 15:06 matinzd

@matinzd I am still encountering the same issue with expo-health-connect.

ytakzk avatar Jul 06 '24 08:07 ytakzk

Can you specify what kind of issue do you have and provide a log or something?

matinzd avatar Jul 06 '24 08:07 matinzd

@matinzd Thank you for your quick reply! I am getting the same error as mentioned in the initial post on Android 10 (I haven't tried it on a different device yet).

Here are the dependencies:

Error:

kotlin.UninitializedPropertyAccessException: lateinit property requestPermission has not been initialized
  dev.matinzd.healthconnect.permissions.HealthConnectPermissionDelegate.launch(HealthConnectPermissionDelegate.kt:32)
  dev.matinzd.healthconnect.HealthConnectManager$requestPermission$1$1.invokeSuspend(HealthConnectManager.kt:64)
  kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
  kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
  kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:115)
  kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:103)
  kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
  kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
  kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
  kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)

Let me know if you need further information on how to reproduce this.

ytakzk avatar Jul 06 '24 08:07 ytakzk

Did you rebuild the project with expo prebuild --clean and added expo-health-connect under plugins in app.json? Can you also try it on newer Android versions? Do you have the same problem on Android 10 when you run this example app?

matinzd avatar Jul 06 '24 20:07 matinzd

@matinzd After closely comparing your example with mine, I discovered that expo-dev-client was somehow interfering with the behavior of expo-health-connect. Once I removed it from package.json, everything started working without errors. Thank you very much!

ytakzk avatar Jul 09 '24 08:07 ytakzk