Repository: IraSoro/peri
Pull request #108 (2023-09-04 21:47:21)

Implement auto-update
In this PR I added the new functionality to update the current app version to the new one. This auto-update works only on the native version of the app (Android), not on the web version

When you start the application, the menu button will have orange badge which shows that new version is available
![image](https://github.com/IraSoro/peri/assets/28269602/6332feab-a8b0-49de-bb9f-293ff5c6b5ed)

On menu button click you'll see button which lets you update your current version
![image](https://github.com/IraSoro/peri/assets/28269602/ad464173-6271-472f-986d-be6fe9a9971d)

That will open native browser and starts downloading
![image](https://github.com/IraSoro/peri/assets/28269602/d792c5db-2867-4c8d-b9f4-fa3a96f3c7f7)

And prompt you to install
![image](https://github.com/IraSoro/peri/assets/28269602/41261b15-ee9f-41ed-83f5-adc6c3b78229)


Patch:
diff --git a/README.md b/README.md
index 321ed2d..4d02eb2 100644
--- a/README.md
+++ b/README.md
@@ -145,7 +145,6 @@ This Docker image already has `@ionic/cli`, `Android Studio` and all necessary J
 
 ```shell
 npm run build # this command builds production-optimized Peri's code
-npm install -g @ionic/cli@7.1.1 # this package will be installed globally to use capacitor
 ionic cap build android # generates Android project
 cd android
 ./gradlew assembleDebug
diff --git a/src/App.tsx b/src/App.tsx
index a1b0242..31477f1 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -46,15 +46,35 @@ import { storage } from "./data/Storage";
 import type { Cycle } from "./data/ClassCycle";
 import { CyclesContext } from "./state/Context";
 import { Menu } from "./modals/Menu";
+import { isNewVersionAvailable } from "./data/AppVersion";
 
 setupIonicReact();
 
+const Badge = () => {
+  // NOTE: Ionic's badge can't be empty and need some text in it,
+  //       that's why I decided to write my own badge component
+  return (
+    <div
+      style={{
+        position: "fixed",
+        right: 5,
+        top: 5,
+        backgroundColor: "orange",
+        minWidth: 10,
+        minHeight: 10,
+        borderRadius: 10,
+      }}
+    />
+  );
+};
+
 const App: React.FC = () => {
   const [cycles, setCycles] = useState<Cycle[]>([]);
   const [isLanguageModal, setIsLanguageModal] = useState(false);
   const [isEditModal, setIsEditModal] = useState(false);
 
   const { t, i18n } = useTranslation();
+  const [needUpdate, setNeedUpdate] = useState(false);
 
   const changeLanguage = useCallback(
     (lng: string) => {
@@ -72,6 +92,19 @@ const App: React.FC = () => {
     storage.set.cycles(newCycles).catch((err) => console.error(err));
   }
 
+  useEffect(() => {
+    isNewVersionAvailable()
+      .then((newVersionAvailable) => {
+        if (!newVersionAvailable) {
+          return;
+        }
+        setNeedUpdate(true);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, []);
+
   useEffect(() => {
     storage.get
       .cycles()
@@ -107,6 +140,7 @@ const App: React.FC = () => {
                     color="light"
                     icon={menuOutline}
                   />
+                  {needUpdate && <Badge />}
                 </IonMenuButton>
               </IonButtons>
             </IonToolbar>
diff --git a/src/data/AppVersion.ts b/src/data/AppVersion.ts
index 2717b88..5f8dcce 100644
--- a/src/data/AppVersion.ts
+++ b/src/data/AppVersion.ts
@@ -1,1 +1,75 @@
-export const appVersion = "2.2.1";
+import { isPlatform } from "@ionic/core";
+
+export const appVersion = "v2.2.1";
+
+export interface GithubReleaseAsset {
+  content_type: string;
+  browser_download_url: string;
+  // NOTE: Other fields are not significant
+  //       You can see all fields with the following command:
+  //       `curl https://api.github.com/repos/IraSoro/peri/releases/latest`
+}
+
+export interface GithubReleaseInfo {
+  tag_name: string;
+  draft: boolean;
+  assets: GithubReleaseAsset[];
+  // NOTE: Other fields are not significant
+  //       You can see all fields with the following command:
+  //       `curl https://api.github.com/repos/IraSoro/peri/releases/latest`
+}
+
+export interface LatestReleaseInfo {
+  version: string;
+  androidApkUrl: string;
+}
+
+async function getLatestReleaseInfo(): Promise<LatestReleaseInfo> {
+  const newVersionInfo = {
+    version: appVersion,
+    androidApkUrl: "",
+  } satisfies LatestReleaseInfo;
+
+  const response = await fetch(
+    "https://api.github.com/repos/IraSoro/peri/releases/latest",
+  );
+
+  const githubReleaseInfo = (await response.json()) as GithubReleaseInfo;
+
+  const apkUrls = githubReleaseInfo.assets.filter((asset) => {
+    return asset.content_type === "application/vnd.android.package-archive";
+  });
+
+  if (!apkUrls.length) {
+    throw new Error("Can't find apk files in assets list");
+  }
+
+  newVersionInfo.version = githubReleaseInfo.tag_name;
+  newVersionInfo.androidApkUrl = apkUrls[0].browser_download_url;
+
+  return newVersionInfo;
+}
+
+export async function isNewVersionAvailable(): Promise<boolean> {
+  if (!isPlatform("android")) {
+    return false;
+  }
+
+  return (await getLatestReleaseInfo()).version > appVersion;
+}
+
+export async function downloadLatestRelease() {
+  const latestRelease = await getLatestReleaseInfo();
+
+  const anchorElement: HTMLAnchorElement = document.createElement("a");
+  anchorElement.download = `peri-${latestRelease.version}.apk`;
+  anchorElement.href = "#";
+  anchorElement.onclick = () => {
+    window.open(latestRelease.androidApkUrl, "_system", "location=yes");
+    return false;
+  };
+
+  document.body.append(anchorElement);
+  anchorElement.click();
+  document.body.removeChild(anchorElement);
+}
diff --git a/src/modals/Menu.tsx b/src/modals/Menu.tsx
index 27533eb..8da05fd 100644
--- a/src/modals/Menu.tsx
+++ b/src/modals/Menu.tsx
@@ -1,4 +1,4 @@
-import { useContext, useRef } from "react";
+import { useContext, useEffect, useRef, useState } from "react";
 import {
   IonButton,
   IonContent,
@@ -14,6 +14,7 @@ import {
   useIonAlert,
 } from "@ionic/react";
 import {
+  arrowDownOutline,
   cloudDownloadOutline,
   cloudUploadOutline,
   createOutline,
@@ -22,7 +23,11 @@ import {
 import { useTranslation } from "react-i18next";
 import { storage } from "../data/Storage";
 import { exportConfig, importConfig } from "../data/Config";
-import { appVersion } from "../data/AppVersion";
+import {
+  appVersion,
+  downloadLatestRelease,
+  isNewVersionAvailable,
+} from "../data/AppVersion";
 import { CyclesContext } from "../state/Context";
 import { useAverageLengthOfCycle } from "../state/CycleInformationHooks";
 import {
@@ -248,6 +253,20 @@ interface MenuProps {
 
 export const Menu = (props: MenuProps) => {
   const { t } = useTranslation();
+  const [needUpdate, setNeedUpdate] = useState(false);
+
+  useEffect(() => {
+    isNewVersionAvailable()
+      .then((newVersionAvailable) => {
+        if (!newVersionAvailable) {
+          return;
+        }
+        setNeedUpdate(true);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, []);
 
   return (
     <IonMenu
@@ -268,10 +287,27 @@ export const Menu = (props: MenuProps) => {
           isOpen={props.isEditModal}
           setIsOpen={props.setIsEditModal}
         />
+        {needUpdate && (
+          <IonItem
+            button
+            onClick={() => {
+              downloadLatestRelease().catch((err) => {
+                console.error(err);
+              });
+            }}
+          >
+            <IonIcon
+              slot="start"
+              color="warning"
+              icon={arrowDownOutline}
+            />
+            <IonLabel color="warning">{t("Download latest version")}</IonLabel>
+          </IonItem>
+        )}
       </IonList>
       <IonItem>
         <IonLabel color="medium">
-          The Period Tracker App Peri v{appVersion}
+          The Period Tracker App Peri {appVersion}
         </IonLabel>
       </IonItem>
     </IonMenu>
diff --git a/src/translations/ru.tsx b/src/translations/ru.tsx
index e6a45a2..8526d92 100644
--- a/src/translations/ru.tsx
+++ b/src/translations/ru.tsx
@@ -91,6 +91,7 @@ const ru = {
   "Export config": "Экспортировать данные",
   "Edit cycles": "Редактировать циклы",
   "Configuration has been imported": "Данные были успешно импортированы",
+  "Download latest version": "Загрузить новую версию",
 };
 
 export default ru;


Test patch:
diff --git a/src/tests/AppVersion.test.ts b/src/tests/AppVersion.test.ts
new file mode 100644
index 0000000..09e9067
--- /dev/null
+++ b/src/tests/AppVersion.test.ts
@@ -0,0 +1,109 @@
+import * as mockedIonicCore from "@ionic/core";
+import {
+  GithubReleaseAsset,
+  GithubReleaseInfo,
+  appVersion,
+  downloadLatestRelease,
+  isNewVersionAvailable,
+} from "../data/AppVersion";
+
+describe("Get information about latest release", () => {
+  test("There are no new version", async () => {
+    jest.spyOn(mockedIonicCore, "isPlatform").mockReturnValue(true);
+
+    globalThis.fetch = jest.fn().mockResolvedValue({
+      json: jest.fn().mockResolvedValue({
+        tag_name: appVersion,
+        draft: false,
+        assets: [
+          {
+            content_type: "application/vnd.android.package-archive",
+            browser_download_url: "https://some-url-to-apk-file.com",
+          },
+        ] satisfies GithubReleaseAsset[],
+      } satisfies GithubReleaseInfo),
+    });
+    await expect(isNewVersionAvailable()).resolves.toEqual(false);
+  });
+
+  test("New version available", async () => {
+    jest.spyOn(mockedIonicCore, "isPlatform").mockReturnValue(true);
+
+    globalThis.fetch = jest.fn().mockResolvedValue({
+      json: jest.fn().mockResolvedValue({
+        tag_name: "v999.999.999",
+        draft: false,
+        assets: [
+          {
+            content_type: "application/vnd.android.package-archive",
+            browser_download_url: "https://some-url-to-apk-file.com",
+          },
+        ] satisfies GithubReleaseAsset[],
+      } satisfies GithubReleaseInfo),
+    });
+    await expect(isNewVersionAvailable()).resolves.toEqual(true);
+  });
+
+  test("Web version", async () => {
+    jest.spyOn(mockedIonicCore, "isPlatform").mockReturnValue(false);
+
+    globalThis.fetch = jest.fn();
+
+    await expect(isNewVersionAvailable()).resolves.toEqual(false);
+    expect(globalThis.fetch).not.toHaveBeenCalled();
+  });
+
+  test("Empty assets list", async () => {
+    jest.spyOn(mockedIonicCore, "isPlatform").mockReturnValue(true);
+
+    globalThis.fetch = jest.fn().mockResolvedValue({
+      json: jest.fn().mockResolvedValue({
+        tag_name: appVersion,
+        draft: false,
+        assets: [] satisfies GithubReleaseAsset[],
+      } satisfies GithubReleaseInfo),
+    });
+
+    await expect(isNewVersionAvailable()).rejects.toThrow(
+      "Can't find apk files in assets list",
+    );
+  });
+});
+
+test("Download latest release", async () => {
+  jest.spyOn(mockedIonicCore, "isPlatform").mockReturnValue(true);
+
+  globalThis.fetch = jest.fn().mockResolvedValue({
+    json: jest.fn().mockResolvedValue({
+      tag_name: "v999.999.999",
+      draft: false,
+      assets: [
+        {
+          content_type: "application/vnd.android.package-archive",
+          browser_download_url: "https://some-url-to-apk-file.com",
+        },
+      ] satisfies GithubReleaseAsset[],
+    } satisfies GithubReleaseInfo),
+  });
+
+  // @ts-expect-error We don't want to implement all methods for `HTMLElement` mock
+  const mockedAnchorElement = {
+    click: jest.fn(),
+  } as HTMLAnchorElement;
+
+  const createElementSpy = jest
+    .spyOn(document, "createElement")
+    .mockReturnValue(mockedAnchorElement);
+
+  const bodyAppendSpy = jest.spyOn(document.body, "append");
+
+  const bodyRemoveChildSpy = jest
+    .spyOn(document.body, "removeChild")
+    .mockReturnValue({} as HTMLAnchorElement);
+
+  await expect(downloadLatestRelease()).resolves.not.toThrow();
+  expect(createElementSpy).toHaveBeenCalledWith("a");
+  expect(bodyAppendSpy).toHaveBeenCalledWith(mockedAnchorElement);
+  expect(mockedAnchorElement.click).toHaveBeenCalled();
+  expect(bodyRemoveChildSpy).toHaveBeenCalledWith(mockedAnchorElement);
+});