diff --git a/.gitignore b/.gitignore index 42a536a72fe..f3792bacc79 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ package-lock.json # App Config .env +.env.local # BUCK buck-out/ diff --git a/img/icons/update-icon.png b/img/icons/update-icon.png new file mode 100644 index 00000000000..b753577f9fb Binary files /dev/null and b/img/icons/update-icon.png differ diff --git a/img/icons/update-icon@2x.png b/img/icons/update-icon@2x.png new file mode 100644 index 00000000000..d68afc65a2b Binary files /dev/null and b/img/icons/update-icon@2x.png differ diff --git a/img/icons/update-icon@3x.png b/img/icons/update-icon@3x.png new file mode 100644 index 00000000000..df440295d2b Binary files /dev/null and b/img/icons/update-icon@3x.png differ diff --git a/locales/en/index.yml b/locales/en/index.yml index 664870f2309..1fc204b2631 100644 --- a/locales/en/index.yml +++ b/locales/en/index.yml @@ -954,3 +954,7 @@ reminders: openMaps: genericError: An error occurred while opening the map genericError: An error occurred +titleUpdateApp: Update IO! +msgErrorUpdateApp: "An error occurred while opening the app store" +messageUpdateApp: "IO often introduces small improvements and new features: to continue using the app you need to update it to the latest version." +btnUpdateApp: Update the IO app diff --git a/locales/it/index.yml b/locales/it/index.yml index 62c8cfe6166..d234a7b9942 100644 --- a/locales/it/index.yml +++ b/locales/it/index.yml @@ -965,3 +965,7 @@ reminders: openMaps: genericError: Si è verificato un errore durante l'apertura della mappa genericError: Si è verificato un errore +titleUpdateApp: Aggiorna IO! +msgErrorUpdateApp: "Si è verificato un errore durante l'apertura dello store delle app" +messageUpdateApp: "IO introduce spesso piccole migliorie e nuove funzioni: per continuare ad usare l'app è necessario aggiornarla all'ultima versione." +btnUpdateApp: Aggiorna l'app IO diff --git a/mock-google-services.json b/mock-google-services.json index 40ceec202a2..690b9a7ef7f 100644 --- a/mock-google-services.json +++ b/mock-google-services.json @@ -10,7 +10,7 @@ "client_info": { "mobilesdk_app_id": "1:111111111111:android:1111111111111111", "android_client_info": { - "package_name": "it.teamdigitale.app.italiaapp" + "package_name": "it.pagopa.io.app" } }, "oauth_client": [ diff --git a/package.json b/package.json index 39943dec7c4..9b3d6eee101 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "remark-html": "9.0.0", "reselect": "4.0.0", "rn-placeholder": "^1.3.3", + "semver": "^5.7.0", "source-map": "^0.6.1", "stacktrace-js": "^2.0.0", "tslib": "^1.9.3", @@ -119,6 +120,7 @@ "@types/react-test-renderer": "16.0.3", "@types/redux-logger": "^3.0.6", "@types/redux-saga": "^0.10.5", + "@types/semver": "^6.2.0", "@types/stacktrace-js": "^0.0.32", "@types/uuid": "^3.4.4", "@types/validator": "^9.4.2", diff --git a/ts/RootContainer.tsx b/ts/RootContainer.tsx index 701e6120c89..e44c91206bf 100644 --- a/ts/RootContainer.tsx +++ b/ts/RootContainer.tsx @@ -27,8 +27,14 @@ import { import { navigateToDeepLink, setDeepLink } from "./store/actions/deepLink"; import { navigateBack } from "./store/actions/navigation"; import { GlobalState } from "./store/reducers/types"; +import UpdateAppModal from "./UpdateAppModal"; import { getNavigateActionFromDeepLink } from "./utils/deepLink"; +// Check min version app supported +import { fromNullable } from "fp-ts/lib/Option"; +import { serverInfoDataSelector } from "./store/reducers/backendInfo"; +import { isUpdatedNeeded } from "./utils/appVersion"; + // tslint:disable-next-line:no-use-before-declare type Props = ReturnType & typeof mapDispatchToProps; @@ -113,6 +119,11 @@ class RootContainer extends React.PureComponent { // FIXME: perhaps instead of navigating to a "background" // screen, we can make this screen blue based on // the redux state (i.e. background) + + // if we have no information about the backend, don't force the update + const isAppOutOfDate = fromNullable(this.props.backendInfo) + .map(bi => isUpdatedNeeded(bi)) + .getOrElse(false); return ( @@ -123,7 +134,7 @@ class RootContainer extends React.PureComponent { )} {shouldDisplayVersionInfoOverlay && } - + {isAppOutOfDate ? : } ); @@ -132,7 +143,8 @@ class RootContainer extends React.PureComponent { const mapStateToProps = (state: GlobalState) => ({ deepLinkState: state.deepLink, - isDebugModeEnabled: state.debug.isDebugModeEnabled + isDebugModeEnabled: state.debug.isDebugModeEnabled, + backendInfo: serverInfoDataSelector(state) }); const mapDispatchToProps = { diff --git a/ts/UpdateAppModal.tsx b/ts/UpdateAppModal.tsx new file mode 100644 index 00000000000..fe0e86e17b3 --- /dev/null +++ b/ts/UpdateAppModal.tsx @@ -0,0 +1,175 @@ +/** + * A screen to invite the user to update the app because current version is not supported yet + * + */ + +import { Millisecond } from "italia-ts-commons/lib/units"; +import { Button, Container, H2, Text, View } from "native-base"; +import * as React from "react"; +import { + BackHandler, + Image, + Linking, + Modal, + Platform, + StyleSheet +} from "react-native"; +import { connect } from "react-redux"; +import BaseScreenComponent from "./components/screens/BaseScreenComponent"; +import FooterWithButtons from "./components/ui/FooterWithButtons"; +import I18n from "./i18n"; +import customVariables from "./theme/variables"; + +const timeoutErrorMsg: Millisecond = 5000 as Millisecond; + +const storeUrl = Platform.select({ + ios: "itms-apps://itunes.apple.com/it/app/testflight/id899247664?mt=8", + android: "market://details?id=it.pagopa.io.app" +}); + +const styles = StyleSheet.create({ + text: { + marginTop: customVariables.contentPadding, + fontSize: 18 + }, + textDanger: { + marginTop: customVariables.contentPadding, + fontSize: 18, + textAlign: "center", + color: customVariables.brandDanger + }, + container: { + margin: customVariables.contentPadding, + flex: 1, + alignItems: "flex-start" + }, + img: { + marginTop: customVariables.contentPaddingLarge, + alignSelf: "center" + } +}); + +type State = { hasError: boolean }; + +class UpdateAppModal extends React.PureComponent { + constructor(props: never) { + super(props); + this.state = { + hasError: false + }; + } + private idTimeout?: number; + // No Event on back button android + private handleBackPress = () => { + return true; + }; + + public componentDidMount() { + BackHandler.addEventListener("hardwareBackPress", this.handleBackPress); + } + + public componentWillUnmount() { + if (this.idTimeout) { + clearTimeout(this.idTimeout); + } + BackHandler.removeEventListener("hardwareBackPress", this.handleBackPress); + } + + private openAppStore = () => { + // the error is already displayed + if (this.state.hasError) { + return; + } + Linking.openURL(storeUrl).catch(() => { + // Change state to show the error message + this.setState({ + hasError: true + }); + // After 5 seconds restore state + // tslint:disable-next-line: no-object-mutation + this.idTimeout = setTimeout(() => { + this.setState({ + hasError: false + }); + }, timeoutErrorMsg); + }); + }; + + /** + * Footer iOS button + */ + private renderIosFooter() { + return ( + + + + + + + ); + } + + /** + * Footer Android buttons + */ + private renderAndroidFooter() { + const cancelButtonProps = { + cancel: true, + block: true, + onPress: () => BackHandler.exitApp(), + title: I18n.t("global.buttons.close") + }; + const updateButtonProps = { + block: true, + primary: true, + onPress: this.openAppStore, + title: I18n.t("btnUpdateApp") + }; + + return ( + + ); + } + + // Different footer according to OS + get footer() { + return Platform.select({ + ios: this.renderIosFooter(), + android: this.renderAndroidFooter() + }); + } + + public render() { + // Current version not supported + return ( + + + + +

{I18n.t("titleUpdateApp")}

+ {I18n.t("messageUpdateApp")} + + {this.state.hasError && ( + + {I18n.t("msgErrorUpdateApp")} + + )} +
+
+
+ {this.footer} +
+ ); + } +} + +export default connect()(UpdateAppModal); diff --git a/ts/store/reducers/backendInfo.ts b/ts/store/reducers/backendInfo.ts index 76de8bb37e9..1e81b5eef83 100644 --- a/ts/store/reducers/backendInfo.ts +++ b/ts/store/reducers/backendInfo.ts @@ -10,6 +10,7 @@ import { backendInfoLoadFailure, backendInfoLoadSuccess } from "../actions/backendInfo"; +import { GlobalState } from "./types"; export type BackendInfoState = Readonly<{ serverInfo?: ServerInfo; @@ -40,3 +41,7 @@ export default function backendInfo( return state; } } + +// Selectors +export const serverInfoDataSelector = (state: GlobalState) => + state.backendInfo.serverInfo; diff --git a/ts/utils/__tests__/appVersion.test.ts b/ts/utils/__tests__/appVersion.test.ts new file mode 100644 index 00000000000..7a71430f56c --- /dev/null +++ b/ts/utils/__tests__/appVersion.test.ts @@ -0,0 +1,71 @@ +jest.mock("react-native-device-info", () => { + return { + getVersion: jest + .fn() + .mockReturnValueOnce("1.1") + .mockReturnValueOnce("1.1.9") + .mockReturnValueOnce("1.2.3.4"), + getBuildNumber: () => 3 + }; +}); +import { getAppVersion, isVersionAppSupported } from "../appVersion"; + +describe("check if getVersion works properly", () => { + it("should be 1.1.3", () => { + expect(getAppVersion()).toEqual("1.1.3"); + }); + + it("should be 1.1.9", () => { + expect(getAppVersion()).toEqual("1.1.9"); + }); + + it("should be 1.2.3.4", () => { + expect(getAppVersion()).toEqual("1.2.3.4"); + }); +}); + +describe("check if app version is supported by backend version", () => { + it("supported", () => { + expect(isVersionAppSupported("0.0.0", "1.2")).toEqual(true); + }); + + it("supported", () => { + expect(isVersionAppSupported("1.1.0", "1.1.0")).toEqual(true); + }); + + it("supported", () => { + expect(isVersionAppSupported("1.4", "1.5.57")).toEqual(true); + }); + + it("supported", () => { + expect(isVersionAppSupported("1.4", "1.4.0.1")).toEqual(true); + }); + + it("supported", () => { + expect(isVersionAppSupported("1.4.0.2", "1.4.0.3")).toEqual(true); + }); + + it("not supported", () => { + expect(isVersionAppSupported("1.4.5", "1.4.1")).toEqual(false); + }); + + it("not supported", () => { + expect(isVersionAppSupported("5", "1.4.1")).toEqual(false); + }); + + it("not supported", () => { + expect(isVersionAppSupported("3.0", "1.4.1")).toEqual(false); + }); + + it("not supported", () => { + expect(isVersionAppSupported("1.1.23", "1.1.21")).toEqual(false); + }); + + it("not supported", () => { + expect(isVersionAppSupported("1.1.2.23", "1.1.1")).toEqual(false); + }); + + it("not supported", () => { + expect(isVersionAppSupported("SOME STRANGE DATA", "1.4.1")).toEqual(true); + }); +}); diff --git a/ts/utils/appVersion.ts b/ts/utils/appVersion.ts new file mode 100644 index 00000000000..9ff12b35c14 --- /dev/null +++ b/ts/utils/appVersion.ts @@ -0,0 +1,48 @@ +import { fromNullable } from "fp-ts/lib/Option"; +import { Platform } from "react-native"; +import DeviceInfo from "react-native-device-info"; +import semver from "semver"; +import { ServerInfo } from "../../definitions/backend/ServerInfo"; + +/** + * return true if appVersion >= minAppVersion + * @param minAppVersion the min version supported + * @param appVersion the version to be tested + */ +export const isVersionAppSupported = ( + minAppVersion: string, + appVersion: string +): boolean => { + const minVersion = semver.coerce(minAppVersion); + const currentAppVersion = semver.coerce(appVersion); + // cant compare + if (!minVersion || !currentAppVersion) { + return true; + } + return semver.satisfies(minVersion, `<=${currentAppVersion}`); +}; + +export const getAppVersion = () => { + const version = DeviceInfo.getVersion(); + // if the version includes only major.minor (we manually ad the buildnumber as patch number) + if (version.split(".").length === 2) { + return `${version}.${DeviceInfo.getBuildNumber()}`; + } + return version; +}; + +/** + * return true if the app must be updated + * @param serverInfo the backend info + */ +export const isUpdatedNeeded = (serverInfo: ServerInfo) => + fromNullable(serverInfo) + .map(si => { + const minAppVersion = Platform.select({ + ios: si.min_app_version.ios, + android: si.min_app_version.android + }); + + return !isVersionAppSupported(minAppVersion, getAppVersion()); + }) + .getOrElse(false); diff --git a/yarn.lock b/yarn.lock index df2d3c17e32..1185746f865 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1425,6 +1425,11 @@ dependencies: redux-saga "*" +"@types/semver@^6.2.0": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-6.2.1.tgz#a236185670a7860f1597cf73bea2e16d001461ba" + integrity sha512-+beqKQOh9PYxuHvijhVl+tIHvT6tuwOrE9m14zd+MT2A38KoKZhh7pYJ0SNleLtwDsiIxHDsIk9bv01oOxvSvA== + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -8880,6 +8885,11 @@ semver@^5.6.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== +semver@^5.7.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + semver@^6.1.2: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"