diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php index e131a97a..45d81306 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -176,6 +176,13 @@ class DaysController extends GenericApiController */ private function preloadDays(array &$days) { + // Do not preload anything for native clients. + // Since the contents of preloads are trusted, clients will not load locals. + if (Util::callerIsNative()) { + return; + } + + // Build identical transforms for sub queries $transforms = $this->getTransformations(false); $preloaded = 0; $preloadDayIds = []; diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 8bb66405..54976816 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -59,8 +59,7 @@ class PageController extends Controller $response->cacheFor(0); // Check if requested from native app - $userAgent = $this->request->getHeader('User-Agent'); - if (false === strpos($userAgent, 'memories-native')) { + if (!Util::callerIsNative()) { $this->eventDispatcher->dispatchTyped(new LoadSidebar()); } diff --git a/lib/Util.php b/lib/Util.php index b62434d3..ed617634 100644 --- a/lib/Util.php +++ b/lib/Util.php @@ -446,6 +446,17 @@ class Util return self::getSystemConfig('instanceid', 'default', true); } + /** + * Checks if the API call was made from a native interface. + */ + public static function callerIsNative(): bool + { + $request = \OC::$server->get(\OCP\IRequest::class); + $userAgent = $request->getHeader('User-Agent'); + + return false !== strpos($userAgent, 'memories-native'); + } + /** * Kill all instances of a process by name. * Similar to pkill, which may not be available on all systems. diff --git a/src/App.vue b/src/App.vue index dc43cc1f..ccd7a48d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -65,6 +65,7 @@ import { translate as t } from '@nextcloud/l10n'; import { emit, subscribe } from '@nextcloud/event-bus'; import * as utils from './services/Utils'; +import * as nativex from './native'; import UserConfig from './mixins/UserConfig'; import Timeline from './components/Timeline.vue'; import Settings from './components/Settings.vue'; @@ -248,7 +249,7 @@ export default defineComponent({ ); // Check for native interface - if (window.nativex?.isNative()) { + if (nativex?.has()) { document.body.classList.add('native'); } }, diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index b25e2217..0eb3f6a5 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -123,6 +123,7 @@ import TopMatter from './top-matter/TopMatter.vue'; import * as dav from '../services/DavRequests'; import * as utils from '../services/Utils'; import * as strings from '../services/strings'; +import * as nativex from '../native'; import { API, DaysFilterType } from '../services/API'; @@ -908,6 +909,16 @@ export default defineComponent({ dayMap.get(photo.dayid)!.push(photo); } + // Get local images if we are running in native environment. + // Get them all together for each day here. + if (nativex.has()) { + const promises: Promise[] = []; + for (const [dayId, photos] of dayMap) { + promises.push(nativex.extendDayWithLocal(dayId, photos)); + } + await Promise.all(promises); + } + // Store cache asynchronously // Do this regardless of whether the state has // changed since the data is already fetched diff --git a/src/components/frame/XImgCache.ts b/src/components/frame/XImgCache.ts index 0b2395e2..062184bf 100644 --- a/src/components/frame/XImgCache.ts +++ b/src/components/frame/XImgCache.ts @@ -1,5 +1,6 @@ import { API } from '../../services/API'; import { workerImporter } from '../../worker'; +import * as nativex from '../../native'; import type * as w from './XImgWorker'; // Global web worker to fetch images @@ -65,6 +66,13 @@ export async function fetchImage(url: string) { let entry = BLOB_CACHE.get(url); if (entry) return entry[1]; + // Check if native image + if (nativex.IS_NATIVE_URL(url)) { + const dataUri = await nativex.getJpegDataUri(url); + BLOB_CACHE.set(url, [60, dataUri]); + return dataUri; + } + // Fetch image const blobUrl = await importer('fetchImageSrc')(url); diff --git a/src/main.ts b/src/main.ts index c0a7bf80..66cc40be 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,7 +13,6 @@ import { getRequestToken } from '@nextcloud/auth'; import type { Route } from 'vue-router'; import type { IPhoto } from './types'; -import type { NativeX } from './types-native'; import type PlyrType from 'plyr'; import type videojsType from 'video.js'; @@ -51,8 +50,6 @@ declare global { var Plyr: typeof PlyrType; var videoClientId: string; var videoClientIdPersistent: string; - - var nativex: NativeX | undefined; } // Allow global access to the router diff --git a/src/native.ts b/src/native.ts new file mode 100644 index 00000000..32a77b76 --- /dev/null +++ b/src/native.ts @@ -0,0 +1,144 @@ +import type { IPhoto } from './types'; + +/** + * Type of a native promise (this will be the exact type in Java). + */ +type NativePromise = (call: string, arg: T) => void; + +/** + * Native interface for the Android app. + */ +export type NativeX = { + isNative: () => boolean; + getLocalByDayId: NativePromise; + getJpeg: NativePromise; +}; + +/** The native interface is a global object that is injected by the native app. */ +const nativex: NativeX = globalThis.nativex; + +/** List of promises that are waiting for a native response. */ +const nativePromises = new Map(); + +/** + * Wraps a native function in a promise. + * JavascriptInterface doesn't support async functions, so we have to do this manually. + * The native function should call `window.nativexr(call, resolve, reject)` when it's done. + * + * @param fun Function to promisify + * @param binary Whether the response is binary (will not be decoded) + */ +function nativePromisify(fun: NativePromise, binary = false): (arg: A) => Promise { + if (!fun) { + return () => { + return new Promise((_, reject) => { + reject('Native function not available'); + }); + }; + } + + return (arg: A) => { + return new Promise((resolve, reject) => { + const call = Math.random().toString(36).substring(7); + nativePromises.set(call, { resolve, reject, binary }); + fun(call, arg); + }); + }; +} + +/** + * Registers the global handler for native responses. + * This should be called by the native app when it's ready to resolve a promise. + * + * @param call ID passed to native function + * @param resolve Response from native function + * @param reject Rejection from native function + */ +globalThis.nativexr = (call: string, resolve?: string, reject?: string) => { + const promise = nativePromises.get(call); + if (!promise) { + console.error('No promise found for call', call); + return; + } + + if (resolve !== undefined) { + if (!(promise as any).binary) resolve = window.atob(resolve); + (promise as any).resolve(resolve); + } else if (reject !== undefined) { + (promise as any).reject(window.atob(reject)); + } else { + console.error('No resolve or reject found for call', call); + return; + } + + nativePromises.delete(call); +}; + +/** + * @returns Whether the native interface is available. + */ +export const has = () => !!nativex; + +/** + * Gets the local photos for a day with a dayId. + * + * @param dayId Day ID to get photos for + * @returns List of local photos (JSON string) + */ +const getLocalByDayId = nativePromisify(nativex?.getLocalByDayId.bind(nativex)); + +/** + * Gets the JPEG data for a photo using a local URI. + * + * @param url Local URI to get JPEG data for + * @returns JPEG data (base64 string) + */ +const getJpeg = nativePromisify(nativex?.getJpeg.bind(nativex), true); + +/** + * Extend a list of photos with local photos. + * Fetches the local photos from the native interface and filters out duplicates. + * + * @param dayId Day ID to append local photos to + * @param photos List of photos to append to (duplicates will not be added) + * @returns + */ +export async function extendDayWithLocal(dayId: number, photos: IPhoto[]) { + if (!has()) return; + + const localPhotos: IPhoto[] = JSON.parse(await getLocalByDayId(dayId)); + const photosSet = new Set(photos.map((p) => p.basename)); + const localOnly = localPhotos.filter((p) => !photosSet.has(p.basename)); + localOnly.forEach((p) => (p.islocal = true)); + photos.push(...localOnly); +} + +/** + * Gets the JPEG data URI for a photo using a native URI. + * + * @param url Native URI to get JPEG data for + * @returns Data URI for JPEG + */ +export async function getJpegDataUri(url: string) { + const image = await getJpeg(url); + return `data:image/jpeg;base64,${image}`; +} + +/** + * Checks whether a URL is a native URI (nativex://). + * + * @param url URL to check + */ +export function IS_NATIVE_URL(url: string) { + return url.startsWith('nativex://'); +} + +/** + * Get a downsized preview URL for a native file ID. + * + * @param fileid Local file ID returned by native interface + * @returns native URI + */ +export function NATIVE_URL_PREVIEW(fileid: number) { + return `nativex://preview/${fileid}`; +} diff --git a/src/services/utils/const.ts b/src/services/utils/const.ts index fd9e8bb0..e134288a 100644 --- a/src/services/utils/const.ts +++ b/src/services/utils/const.ts @@ -9,6 +9,7 @@ export const constants = { FLAG_IS_FAVORITE: 1 << 3, FLAG_SELECTED: 1 << 4, FLAG_LEAVING: 1 << 5, + FLAG_IS_LOCAL: 1 << 6, }, }; @@ -30,4 +31,8 @@ export function convertFlags(photo: IPhoto) { photo.flag |= constants.c.FLAG_IS_FAVORITE; delete photo.isfavorite; } + if (photo.islocal) { + photo.flag |= constants.c.FLAG_IS_LOCAL; + delete photo.islocal; + } } diff --git a/src/services/utils/helpers.ts b/src/services/utils/helpers.ts index cfe56c09..82e04ebe 100644 --- a/src/services/utils/helpers.ts +++ b/src/services/utils/helpers.ts @@ -1,8 +1,15 @@ import { IImageInfo, IPhoto } from '../../types'; import { API } from '../API'; +import { constants } from './const'; +import * as nativex from '../../native'; /** Get preview URL from photo object */ export function getPreviewUrl(photo: IPhoto, square: boolean, size: number | [number, number] | 'screen') { + // Native preview + if (photo.flag & constants.c.FLAG_IS_LOCAL) { + return nativex.NATIVE_URL_PREVIEW(photo.fileid); + } + // Screen-appropriate size if (size === 'screen') { const sw = Math.floor(screen.width * devicePixelRatio); diff --git a/src/types-native.ts b/src/types-native.ts deleted file mode 100644 index 9165dcad..00000000 --- a/src/types-native.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type NativeX = { - isNative: () => boolean; -}; diff --git a/src/types.ts b/src/types.ts index 34f25cda..0bf252e8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,6 +71,8 @@ export type IPhoto = { video_duration?: number; /** Favorite flag from server */ isfavorite?: boolean; + /** Local file from native */ + islocal?: boolean; /** Optional datetaken epoch */ datetaken?: number; };