Image Fallback API integration for non-signed urls | Himanshu (#52)

* non-signed images handle

* Fix review comment

* Saving & Fetching customer images from wm db

* Review comment resolve

* Fix component break

* Reuse component CaseItemAvatar

* Fix review comment

* Minor fix
This commit is contained in:
Himanshu Kansal
2023-01-12 18:13:56 +05:30
committed by GitHub Enterprise
parent b1e304e4ad
commit 635ca7b675
17 changed files with 336 additions and 28 deletions

View File

@@ -41,7 +41,8 @@
"react-native-toast-message": "2.1.5",
"react-redux": "8.0.5",
"redux": "4.2.0",
"redux-persist": "6.0.0"
"redux-persist": "6.0.0",
"rn-fetch-blob": "0.12.0"
},
"devDependencies": {
"@babel/core": "7.12.9",

View File

@@ -18,6 +18,7 @@ import {
} from '../reducer/allCasesSlice';
import { ICaseItem } from '../screens/allCases/interface';
import { AppDispatch } from '../store/store';
import { logError } from '../components/utlis/errorUtils';
export const getAllCases =
() => (dispatch: AppDispatch) => {
@@ -131,3 +132,19 @@ export const getFilters = () => (dispatch: AppDispatch) => {
dispatch(setFilters(response.data));
});
};
export const getSignedURLs = (urlList: string[]) => (dispatch: AppDispatch) => {
const url = getApiUrl(ApiKeys.IMAGE_SIGNED_URLS);
dispatch(setLoading(true));
return axiosInstance.post(url, urlList).then(response => {
if(response?.data) {
return response.data;
}
throw response;
}).catch((err) => {
logError(err);
})
.finally(() => {
dispatch(setLoading(false));
})
};

View File

@@ -1,3 +1,5 @@
import { CaseStatuses } from "../screens/allCases/interface";
export enum CONDITIONAL_OPERATORS {
EQUALS = 'EQUALS',
LESS_THAN_EQUAL_TO = 'LESS_THAN_EQUAL_TO',
@@ -24,3 +26,16 @@ export enum FirestoreUpdateTypes {
MODIFIED = 'modified',
REMOVED = 'removed',
}
export enum MimeType {
'image/jpeg' = 'image/jpeg',
'image/png' = 'image/png'
}
export const ClosedCaseStatusList = [CaseStatuses.CLOSED, CaseStatuses.FORCE_CLOSE, CaseStatuses.EXPIRED];
export const getPrefixBase64Image = (contentType: MimeType) => {
return `data:${contentType};base64,}`;
}
export const PrefixJpegBase64Image = getPrefixBase64Image(MimeType["image/jpeg"]);

View File

@@ -20,6 +20,7 @@ import { AnswerType } from '../interface';
import ErrorMessage from './ErrorMessage';
import withObservables from '@nozbe/with-observables';
import OfflineImageDAO from '../../../wmDB/dao/OfflineImageDAO';
import { PrefixJpegBase64Image } from '../../../common/Constants';
interface IOfflineImage {
idx: string;
@@ -63,7 +64,7 @@ const ImageUpload: React.FC<IImageUpload> = props => {
clickedImage: string | null,
onChange: (...event: any[]) => void,
) => {
const data = `data:image/jpeg;base64,${clickedImage}`;
const data = `${PrefixJpegBase64Image}${clickedImage}`;
var uniqueId = 'id' + (new Date()).getTime();
setImageId(uniqueId);
onChange({

View File

@@ -23,6 +23,7 @@ export enum ApiKeys {
LOGOUT,
FEEDBACK,
FILTERS,
IMAGE_SIGNED_URLS
}
const API_URLS: Record<ApiKeys, string> = {} as Record<ApiKeys, string>;
@@ -34,6 +35,7 @@ API_URLS[ApiKeys.PINNED_CASES] = '/cases/pin';
API_URLS[ApiKeys.LOGOUT] = '/auth/logout';
API_URLS[ApiKeys.FEEDBACK] = '/cases/feedback';
API_URLS[ApiKeys.FILTERS] = '/cases/filters';
API_URLS[ApiKeys.IMAGE_SIGNED_URLS] = '/cases/get-signed-urls';
const MOCK_API_URLS: Record<ApiKeys, string> = {} as Record<ApiKeys, string>;

View File

@@ -1,5 +1,12 @@
import RNFetchBlob from "rn-fetch-blob";
import { getPrefixBase64Image, MimeType } from "../../common/Constants";
import { Address } from "../../screens/caseDetails/interface";
const fs = RNFetchBlob.fs;
const RespHeaderContentTypeKey = 'Content-Type'
export const decideLoadingState = (textData: string): boolean => {
if (!textData) {
return true;
@@ -17,3 +24,24 @@ export const getCommunicationAddress = (address: Address) => {
const { houseNumber, lineOne, lineTwo, locality, street, city, state, pinCode } = address;
return [houseNumber, lineOne, lineTwo, locality, street, city, state, pinCode].filter(element => element).join(', ');
}
export const getBase64FromUrl = async (imagePath: string) => {
let contentType: MimeType;
return RNFetchBlob.config({
fileCache: true
}).fetch("GET", imagePath)
.then(resp => {
if (resp) {
imagePath = resp.path();
contentType = resp.respInfo?.headers?.[RespHeaderContentTypeKey];
return resp.readFile("base64");
}
})
.then(base64 => {
fs.unlink(imagePath);
if (contentType) {
return `${getPrefixBase64Image(contentType)}${base64}`;
}
return;
});
}

View File

@@ -0,0 +1,55 @@
import { ClosedCaseStatusList } from "../../common/Constants";
import { GenericType } from "../../common/GenericTypes";
import { CustomerImageDAO } from "../../wmDB";
import { getBase64FromUrl } from "./commonFunctions";
const isCaseIdRecordExistOnCustomerDb = async (caseId: string) => {
let imageList = await CustomerImageDAO.getImageByCase(caseId);
return !!imageList?.length;
}
export const getImage64FromCaseId = async (caseId: string) => {
let imageList = await CustomerImageDAO.getImageByCase(caseId);
return imageList?.[0]?.image64;
}
export const updateRecordsOnCustomerDbFromCaseList = (caseList: GenericType[]) => {
if (!caseList?.length) return;
const mappedCaseList = caseList
.map((caseItem: GenericType) => (
{
imageURL: caseItem?.customerInfo?.imageURL,
caseId: caseItem?.id,
caseStatus: caseItem?.caseStatus
}
));
const filteredMappedCaseList = [];
const closedCaseIdList = [];
(async () => {
for(const caseItem of mappedCaseList) {
if (caseItem.caseId && caseItem.imageURL) {
const isCaseIdRecordExist = await isCaseIdRecordExistOnCustomerDb(caseItem.caseId);
if(isCaseIdRecordExist && ClosedCaseStatusList.includes(caseItem.caseStatus)) {
closedCaseIdList.push(caseItem.caseId);
}
if (!isCaseIdRecordExist && !ClosedCaseStatusList.includes(caseItem.caseStatus)) {
const image64 = await getBase64FromUrl(caseItem.imageURL);
if(image64) {
filteredMappedCaseList.push({...caseItem, image64});
}
}
}
}
if (filteredMappedCaseList?.length) {
await CustomerImageDAO.addBulkCustomerImage(filteredMappedCaseList);
}
if (closedCaseIdList?.length) {
await CustomerImageDAO.deleteImages(closedCaseIdList);
}
})();
}

View File

@@ -6,6 +6,7 @@ import {
} from '../common/Constants';
import { CaseDetail } from '../screens/caseDetails/interface';
import { CaseUpdates } from '../hooks/useFirestoreUpdates';
import { updateRecordsOnCustomerDbFromCaseList } from '../components/utlis/customerDbHelper';
import { _map } from '../../RN-UI-LIB/src/utlis/common';
export type ICasesMap = { [key: string]: ICaseItem };
@@ -119,6 +120,8 @@ const allCasesSlice = createSlice({
},
setCasesDetailsData: (state, action) => {
const { details } = action.payload;
updateRecordsOnCustomerDbFromCaseList(details);
if (details?.length) {
details.forEach((caseDetail: CaseDetail) => {
const { id } = caseDetail;

View File

@@ -1,40 +1,83 @@
import React from 'react';
import {ICaseItem} from './interface';
import RoundCheckIcon from '../../icons/RoundCheckIcon';
import {useAppSelector} from '../../hooks';
import {useAppDispatch, useAppSelector} from '../../hooks';
import {CaseDetail} from '../caseDetails/interface';
import Avatar from '../../../RN-UI-LIB/src/components/Avatar';
import UnsyncedIcon from '../../../RN-UI-LIB/src/Icons/UnsyncedIcon';
import {StyleSheet, View} from 'react-native';
import { getSignedURLs } from '../../action/dataActions';
import { getImage64FromCaseId } from '../../components/utlis/customerDbHelper';
interface ICaseItemAvatar {
caseSelected: boolean;
caseSelected?: boolean;
caseData: ICaseItem;
size?: number;
}
const MAX_API_CALL = 3;
const CaseItemAvatar: React.FC<ICaseItemAvatar> = ({
caseSelected,
caseSelected = false,
caseData,
size = 32
}) => {
const dispatch = useAppDispatch();
const {caseReferenceId} = caseData;
const caseDetails: CaseDetail = useAppSelector(
state => state.allCases.caseDetails?.[caseReferenceId],
);
if (caseSelected) {
return <RoundCheckIcon />;
}
const [imageUrl, setImageUrl] = React.useState<string>('');
const [apiErrorCount, setApiErrorCount] = React.useState(0);
const isSynced = caseDetails?.isSynced;
React.useEffect(() => {
if (caseDetails?.customerInfo?.imageURL) {
(async () => {
let image64 = await getImage64FromCaseId(caseDetails?.id);
if (image64) { // if image exist in WM db, then setting the base64
setImageUrl(image64);
} else {
setImageUrl(caseDetails.customerInfo.imageURL)
}
})();
}
}, [caseDetails?.customerInfo?.imageURL]);
const onError = async () => {
if (apiErrorCount < MAX_API_CALL) {
dispatch(getSignedURLs([imageUrl])).then((resp) => {
if (resp?.[imageUrl]) {
setApiErrorCount(apiErrorCount => apiErrorCount+1);
setImageUrl(resp[imageUrl])
}
})
}
}
return (
<View>
<Avatar
name={caseDetails?.customerInfo?.name || caseDetails?.customerInfo?.customerName }
dataURI={caseDetails.customerInfo.imageURL}
/>
{!isSynced ? (
<View style={styles.unsyncedIcon}>
<UnsyncedIcon />
</View>
) : null}
{
!caseSelected ? (
<View>
<Avatar
size={size}
name={caseDetails?.customerInfo?.name || caseDetails?.customerInfo?.customerName }
dataURI={imageUrl}
onErrorFallback={onError}
/>
{!isSynced ? (
<View style={styles.unsyncedIcon}>
<UnsyncedIcon />
</View>
) : null}
</View>
) : <RoundCheckIcon />
}
</View>
);
};

View File

@@ -21,6 +21,8 @@ import CalenderIcon from '../../assets/icons/CalenderIcon';
import DocumentIcon from '../../assets/icons/DocumentIcon';
import IconLabel from '../../common/IconLabel';
import { decideLoadingState } from '../../components/utlis/commonFunctions';
import CaseItemAvatar from '../allCases/CaseItemAvatar';
import { ICaseItem } from '../allCases/interface';
import {
CaseDetail,
LoanAccountStatusUIMapping,
@@ -64,16 +66,10 @@ const UserDetailsSection: React.FC<IUserDetailsSection> = props => {
/>
}>
<TouchableOpacity onPress={handleOpenClose}>
<Avatar
<CaseItemAvatar
size={64}
name={customerInfo?.customerName || customerInfo?.name || ''}
dataURI={customerInfo?.imageURL}
caseData={{...caseDetail, caseReferenceId: caseDetail?.id} as ICaseItem}
/>
{!isSynced ? (
<View style={styles.unsyncedIcon}>
<UnsyncedIcon />
</View>
) : null}
</TouchableOpacity>
</SuspenseLoader>
<View style={[styles.infoContainer]}>

View File

@@ -1,7 +1,8 @@
export enum TableName {
OFFLINE_IMAGES = 'offline_image',
CUSTOMER_IMAGE = 'customer_image'
};
export const DB_VERSION = 1;
export const DB_VERSION = 2;
export const DB_NAME = 'AVAPP';

View File

@@ -0,0 +1,94 @@
import { database } from '../db';
import { Q } from '@nozbe/watermelondb'
import { TableName } from '../const';
import { logError } from '../../components/utlis/errorUtils';
const customerImage = database.get(TableName.CUSTOMER_IMAGE);
interface ICustomerOfflineImage {
caseId: string;
imageURL: string;
image64: string;
}
export default {
observeOfflineImage: () => customerImage.query().observe(),
addBulkCustomerImage: async (customerImageList: ICustomerOfflineImage[]) => {
function prepareInsertion(customerImageList: ICustomerOfflineImage[]) {
return customerImageList.map(customerImageItem => {
try {
return customerImage.prepareCreate((imageRow: any) => {
imageRow.caseId = customerImageItem.caseId;
imageRow.imageURL = customerImageItem.imageURL;
imageRow.image64 = customerImageItem.image64;
});
} catch (e) {
logError(e as Error, "WM DB")
}
});
}
try {
return await database.action(async () => {
const allRecords = prepareInsertion(customerImageList);
await database.batch(...allRecords);
});
} catch (e) {
logError(e as Error, "WM DB")
}
},
addCustomerImage: async (customerImageItem: ICustomerOfflineImage) => {
try {
return await database.action(async () => {
return await customerImage.create((imageRow: any) => {
imageRow.caseId = customerImageItem.caseId;
imageRow.imageURL = customerImageItem.imageURL;
imageRow.image64 = customerImageItem.image64;
});
});
} catch (e) {
logError(e as Error, "WM DB")
}
},
getImageByURL: async (imageURL: string) => {
try {
return await database.action(async () => {
return await customerImage.query(Q.where('image_url', imageURL), Q.take(1)).fetch()
});
} catch (e) {
logError(e as Error, "WM DB")
return;
}
},
getImageByCase: async (caseId: string) => {
try {
return await database.action(async () => {
return await customerImage.query(Q.where('case_id', caseId), Q.take(1)).fetch()
});
} catch (e) {
logError(e as Error, "WM DB")
return;
}
},
deleteImages: async (caseIds: Array<string> | string) => {
let tCaseIds = Array.isArray(caseIds) ? caseIds : [caseIds];
try {
return await database.action(async () => {
await customerImage.query(Q.where('case_id', Q.oneOf(tCaseIds))).destroyAllPermanently()
});
} catch (e) {
logError(e as Error, "WM DB")
return;
}
},
deleteAll: async () => {
try {
return await database.action(async () => {
await customerImage.query().destroyAllPermanently();
});
} catch (e) {
logError(e as Error, "WM DB")
return;
}
},
};

View File

@@ -1,6 +1,7 @@
import { Database } from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import { DB_NAME } from './const';
import CustomerImage from './model/CustomerImage';
import OfflineImage from './model/OfflineImage';
import schema from './schema';
@@ -12,7 +13,7 @@ const adapter = new SQLiteAdapter({
const database = new Database({
adapter,
modelClasses: [OfflineImage]
modelClasses: [OfflineImage, CustomerImage]
});
export { database };

View File

@@ -1,4 +1,5 @@
import PostDAO from './dao/OfflineImageDAO';
import CustomerImageDAO from './dao/CustomerImageDAO';
export { database } from './db';
export { PostDAO };
export { PostDAO, CustomerImageDAO };

View File

@@ -0,0 +1,17 @@
import { Model } from '@nozbe/watermelondb';
import {
field,
readonly,
date,
} from '@nozbe/watermelondb/decorators';
import { TableName } from '../const';
export default class CustomerImage extends Model {
static table = TableName.CUSTOMER_IMAGE;
@field('case_id') caseId!: string;
@field('image_url') imageURL!: string;
@field('image_64') image64!: string;
@readonly @date('created_at') createdAt!: any;
@readonly @date('updated_at') updatedAt!: any;
}

View File

@@ -10,6 +10,14 @@ export default appSchema({
{ name: 'idx', type: 'string' },
{ name: 'image_data', type: 'string' },
]
}),
tableSchema({
name: TableName.CUSTOMER_IMAGE,
columns: [
{ name: 'case_id', type: 'string' },
{ name: 'image_url', type: 'string' },
{ name: 'image_64', type: 'string' },
]
})
],
});

View File

@@ -2531,6 +2531,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base-64@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb"
integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==
base64-js@^1.1.2, base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -4137,6 +4142,18 @@ glob-parent@^6.0.2:
dependencies:
is-glob "^4.0.3"
glob@7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.6.tgz#211bafaf49e525b8cd93260d14ab136152b3f57a"
integrity sha512-f8c0rE8JiCxpa52kWPAOa3ZaYEnzofDzCQLCn3Vdk0Z5OVLq3BsRFJI4S4ykpeVW6QMGBUkMeUpoEgWnMTnw5Q==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.2"
once "^1.3.0"
path-is-absolute "^1.0.0"
glob@7.1.6:
version "7.1.6"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
@@ -7060,6 +7077,14 @@ rimraf@~2.6.2:
dependencies:
glob "^7.1.3"
rn-fetch-blob@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/rn-fetch-blob/-/rn-fetch-blob-0.12.0.tgz#ec610d2f9b3f1065556b58ab9c106eeb256f3cba"
integrity sha512-+QnR7AsJ14zqpVVUbzbtAjq0iI8c9tCg49tIoKO2ezjzRunN7YL6zFSFSWZm6d+mE/l9r+OeDM3jmb2tBb2WbA==
dependencies:
base-64 "0.1.0"
glob "7.0.6"
rsvp@^4.8.4:
version "4.8.5"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"