Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
98dad31
feat(photos): replace legacy Photos API with Google Photos Picker API
AhsanIsEpic Apr 9, 2026
095c532
chore: bump version to 4.3.2
AhsanIsEpic Apr 9, 2026
61f4484
chore: revert version to 4.3.1
AhsanIsEpic Apr 9, 2026
f65727d
fix(photos): address remaining Copilot review comments
AhsanIsEpic Apr 9, 2026
8f67c61
fix(photos): fix polling interval leak and progress text pluralization
AhsanIsEpic Apr 9, 2026
75caf00
fix(photos): address Copilot review comments round 3
AhsanIsEpic Apr 9, 2026
2e3b212
fix(photos): address Copilot review comments round 4
AhsanIsEpic Apr 9, 2026
5e30d29
refactor(photos): replace ID-map dedup with filesystem existence check
AhsanIsEpic Apr 9, 2026
e6f362d
refactor(photos): use file metadata for cross-session dedup
AhsanIsEpic Apr 9, 2026
5ec5dd0
fix(photos): address Copilot review comments round 5
AhsanIsEpic Apr 9, 2026
a20aec7
feat(photos): support queueing multiple picker sessions
AhsanIsEpic Apr 9, 2026
98496e9
fix(photos): rename cancel buttons and fix picker button gap
AhsanIsEpic Apr 9, 2026
f10c644
fix(photos): avoid transient importing_photos=0 on queue transition; …
AhsanIsEpic Apr 9, 2026
b033102
fix(photos): address Copilot review comments round 6
AhsanIsEpic Apr 9, 2026
4ae52bf
fix(photos): address Copilot review comments round 7
AhsanIsEpic Apr 10, 2026
e82ce7f
fix(photos): address Copilot review comments round 8
AhsanIsEpic Apr 10, 2026
f93117a
fix(photos): clear picker session queue and page token on import error
AhsanIsEpic Apr 10, 2026
980221a
fix(photos): address maintainer review comments
AhsanIsEpic Apr 10, 2026
30edd1f
fix(photos): correct copyright year to 2026
AhsanIsEpic Apr 10, 2026
3ab61e8
fix(photos): address Copilot review comments round 9
AhsanIsEpic Apr 10, 2026
037d5a2
fix(photos): validate queue json_decode result with is_array() check
AhsanIsEpic Apr 10, 2026
347cb69
fix: address Copilot review comments round 10
AhsanIsEpic Apr 10, 2026
5eefc37
fix: address Copilot review comments round 11
AhsanIsEpic Apr 13, 2026
6e0c8cf
fix: address maintainer review comments (lukasdotcom) round 1
AhsanIsEpic Apr 17, 2026
3c524d9
fix: wrap cleanup delete/unlock calls in try/catch in downloadAndSave…
AhsanIsEpic Apr 17, 2026
df80076
Address comments
AhsanIsEpic Apr 29, 2026
2c794e4
README changes
AhsanIsEpic Apr 29, 2026
b98709a
Resolve minor comments
AhsanIsEpic Apr 30, 2026
80cd6ea
reverted the popup flow back to the older opener-based behavior
AhsanIsEpic Apr 30, 2026
ba86c4e
Remove correct CSS
AhsanIsEpic Apr 30, 2026
b232363
Remove popup focus guard
AhsanIsEpic Apr 30, 2026
9437041
Revert popup handler changes
AhsanIsEpic Apr 30, 2026
ff6ddbe
Simplify finished photo import flow
AhsanIsEpic Apr 30, 2026
269cb43
Update src/components/PersonalSettings.vue
AhsanIsEpic May 1, 2026
198c264
removed the “restart polling” block from onImportPhotos().catch(...)
AhsanIsEpic May 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ If there is a strong business case for any development of this app, we will cons

## Limitations

This app can not migrate Google photos files due to limitations in the Google Photos API making it too complex for end users.
For more information please visit [the Google Photos Documentation.](https://developers.google.com/photos/support/updates#affected-scopes-methods)
- Google Photos import is selection-based and does not import an entire library automatically. It is limited to 2000 items per import session, and users must manually select which photos and videos to import.
- Google does not provide location data for imported photos and imported videos may be lower quality than the original files provided in Google Photos.
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
['name' => 'googleAPI#getContactNumber', 'url' => '/contact-number', 'verb' => 'GET'],
['name' => 'googleAPI#importCalendar', 'url' => '/import-calendar', 'verb' => 'GET'],
['name' => 'googleAPI#importContacts', 'url' => '/import-contacts', 'verb' => 'GET'],
['name' => 'googleAPI#createPickerSession', 'url' => '/picker-session', 'verb' => 'POST'],
['name' => 'googleAPI#getPickerSession', 'url' => '/picker-session', 'verb' => 'GET'],
['name' => 'googleAPI#deletePickerSession', 'url' => '/picker-session', 'verb' => 'DELETE'],
['name' => 'googleAPI#importPhotos', 'url' => '/import-photos', 'verb' => 'POST'],
['name' => 'googleAPI#getImportPhotosInformation', 'url' => '/import-photos-info', 'verb' => 'GET'],
Comment thread
AhsanIsEpic marked this conversation as resolved.
['name' => 'googleAPI#importDrive', 'url' => '/import-files', 'verb' => 'GET'],
['name' => 'googleAPI#getImportDriveInformation', 'url' => '/import-files-info', 'verb' => 'GET'],
]
Expand Down
38 changes: 38 additions & 0 deletions lib/BackgroundJob/ImportPhotosJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/**
* Nextcloud - integration_google
*
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
* @author Ahsan Ahmed
* @copyright Nextcloud GmbH and Nextcloud contributors 2026
*/

namespace OCA\Google\BackgroundJob;

use OCA\Google\Service\GooglePhotosAPIService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;

/**
* A QueuedJob to partially import google photos and launch following job
*/
class ImportPhotosJob extends QueuedJob {

public function __construct(
ITimeFactory $timeFactory,
private GooglePhotosAPIService $service,
) {
parent::__construct($timeFactory);
}

/**
* @param array{user_id:string} $argument
*/
public function run($argument) {
$userId = $argument['user_id'];
$this->service->importPhotosJob($userId);
}
}
14 changes: 13 additions & 1 deletion lib/Controller/ConfigController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use OCA\Google\AppInfo\Application;
use OCA\Google\Service\GoogleAPIService;
use OCA\Google\Service\GoogleDriveAPIService;
use OCA\Google\Service\GooglePhotosAPIService;
use OCA\Google\Service\SecretService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
Expand All @@ -41,8 +42,9 @@ class ConfigController extends Controller {
public const CONTACTS_OTHER_SCOPE = 'https://www.googleapis.com/auth/contacts.other.readonly';
public const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.readonly';
public const CALENDAR_EVENTS_SCOPE = 'https://www.googleapis.com/auth/calendar.events.readonly';
public const PHOTOS_SCOPE = 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly';

public const INT_CONFIGS = ['nb_imported_files', 'drive_imported_size', 'last_drive_import_timestamp', 'drive_import_job_last_start'];
public const INT_CONFIGS = ['nb_imported_files', 'drive_imported_size', 'last_drive_import_timestamp', 'drive_import_job_last_start', 'nb_imported_photos', 'last_photo_import_timestamp', 'photo_import_job_last_start'];

public function __construct(
string $appName,
Expand All @@ -55,6 +57,7 @@ public function __construct(
private IInitialState $initialStateService,
private GoogleAPIService $googleApiService,
private GoogleDriveAPIService $googleDriveApiService,
private GooglePhotosAPIService $googlePhotosApiService,
private ?string $userId,
private ICrypto $crypto,
private SecretService $secretService,
Expand Down Expand Up @@ -90,6 +93,11 @@ public function setConfig(array $values): DataResponse {
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'refresh_token');
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'token_expires_at');
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'token');
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'user_scopes');
// Cancel any in-progress imports so background jobs don't keep re-queuing
// and failing against the now-deleted tokens
$this->googleDriveApiService->cancelImport($this->userId);
$this->googlePhotosApiService->cancelImport($this->userId);
$result['user_name'] = '';
} else {
if (isset($values['drive_output_dir'])) {
Expand All @@ -100,6 +108,9 @@ public function setConfig(array $values): DataResponse {
if (isset($values['importing_drive']) && $values['importing_drive'] === '0') {
$this->googleDriveApiService->cancelImport($this->userId);
}
if (isset($values['importing_photos']) && $values['importing_photos'] === '0') {
$this->googlePhotosApiService->cancelImport($this->userId);
}
}
return new DataResponse($result);
}
Expand Down Expand Up @@ -192,6 +203,7 @@ public function oauthRedirect(string $code = '', string $state = '', string $sco
'can_access_contacts' => in_array(self::CONTACTS_SCOPE, $scopes) ? 1 : 0,
'can_access_other_contacts' => in_array(self::CONTACTS_OTHER_SCOPE, $scopes) ? 1 : 0,
'can_access_calendar' => (in_array(self::CALENDAR_SCOPE, $scopes) && in_array(self::CALENDAR_EVENTS_SCOPE, $scopes)) ? 1 : 0,
'can_access_photos' => in_array(self::PHOTOS_SCOPE, $scopes) ? 1 : 0,
];

$this->userConfig->setValueString($this->userId, Application::APP_ID, 'user_scopes', json_encode($scopesArray), lazy: true);
Expand Down
102 changes: 102 additions & 0 deletions lib/Controller/GoogleAPIController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use OCA\Google\Service\GoogleCalendarAPIService;
use OCA\Google\Service\GoogleContactsAPIService;
use OCA\Google\Service\GoogleDriveAPIService;
use OCA\Google\Service\GooglePhotosAPIService;
use OCA\Google\Service\SecretService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
Expand All @@ -30,6 +31,7 @@ public function __construct(
string $appName,
IRequest $request,
private IUserConfig $userConfig,
private GooglePhotosAPIService $googlePhotosAPIService,
private GoogleContactsAPIService $googleContactsAPIService,
private GoogleDriveAPIService $googleDriveAPIService,
private GoogleCalendarAPIService $googleCalendarAPIService,
Expand All @@ -40,6 +42,106 @@ public function __construct(
$this->accessToken = $this->userId !== null ? $this->secretService->getEncryptedUserValue($this->userId, 'token') : '';
}


/**
* @NoAdminRequired
*
* @return DataResponse
*/
public function getImportPhotosInformation(): DataResponse {
if ($this->accessToken === '') {
return new DataResponse([], 400);
}
$pickerSessionQueue = json_decode(
$this->userConfig->getValueString($this->userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true),
true,
);
if (!is_array($pickerSessionQueue)) {
$pickerSessionQueue = [];
}
return new DataResponse([
'importing_photos' => $this->userConfig->getValueString($this->userId, Application::APP_ID, 'importing_photos', lazy: true) === '1',
'last_photo_import_timestamp' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'last_photo_import_timestamp', lazy: true),
'nb_imported_photos' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_imported_photos', lazy: true),
'nb_queued_sessions' => count($pickerSessionQueue),
]);
}

/**
* @NoAdminRequired
*
* Create a new Google Photos Picker session (Picker API)
*
* @return DataResponse
*/
public function createPickerSession(): DataResponse {
if ($this->accessToken === '' || $this->userId === null) {
return new DataResponse([], 400);
}
$result = $this->googlePhotosAPIService->createPickerSession($this->userId);
if (isset($result['error'])) {
return new DataResponse($result['error'], 401);
}
return new DataResponse($result);
}

/**
* @NoAdminRequired
*
* Poll a Google Photos Picker session
*
* @param string $sessionId
* @return DataResponse
*/
public function getPickerSession(string $sessionId): DataResponse {
if ($this->accessToken === '' || $this->userId === null) {
return new DataResponse([], 400);
}
$result = $this->googlePhotosAPIService->getPickerSession($this->userId, $sessionId);
if (isset($result['error'])) {
return new DataResponse($result['error'], 401);
}
return new DataResponse($result);
}

/**
* @NoAdminRequired
*
* Delete a Google Photos Picker session
*
* @param string $sessionId
* @return DataResponse
*/
public function deletePickerSession(string $sessionId): DataResponse {
if ($this->accessToken === '' || $this->userId === null) {
return new DataResponse([], 400);
}
$result = $this->googlePhotosAPIService->deletePickerSession($this->userId, $sessionId);
if (isset($result['error'])) {
return new DataResponse($result['error'], 401);
}
return new DataResponse($result);
}

/**
* @NoAdminRequired
*
* Start downloading photos from a completed Picker session
*
* @param string $sessionId
* @return DataResponse
*/
public function importPhotos(string $sessionId = ''): DataResponse {
if ($this->accessToken === '' || $this->userId === null) {
return new DataResponse([], 400);
}
$result = $this->googlePhotosAPIService->startImportPhotos($this->userId, $sessionId);
if (isset($result['error'])) {
return new DataResponse($result['error'], 401);
}
return new DataResponse($result);
}

/**
* @NoAdminRequired
*
Expand Down
12 changes: 12 additions & 0 deletions lib/Notification/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ public function prepare(INotification $notification, string $languageCode): INot
$l = $this->factory->get('integration_google', $languageCode);

switch ($notification->getSubject()) {
case 'import_photos_finished':
/** @var array{nbImported?:string, targetPath: string} $p */
$p = $notification->getSubjectParameters();
$nbImported = (int)($p['nbImported'] ?? 0);
$targetPath = $p['targetPath'];
$content = $l->n('%n photo was imported from Google.', '%n photos were imported from Google.', $nbImported);

$notification->setParsedSubject($content)
->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')))
->setLink($this->url->linkToRouteAbsolute('files.view.index', ['dir' => $targetPath]));
return $notification;

case 'import_drive_finished':
/** @var array{nbImported?:string, targetPath: string} $p */
$p = $notification->getSubjectParameters();
Expand Down
70 changes: 70 additions & 0 deletions lib/Service/GoogleAPIService.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@
use OCA\Google\AppInfo\Application;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Config\IUserConfig;
use OCP\Files\Folder;
use OCP\Files\InvalidPathException;
use OCP\Files\NotPermittedException;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IL10N;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
use OCP\Notification\IManager as INotificationManager;
use Psr\Log\LoggerInterface;
use Throwable;
Expand Down Expand Up @@ -353,6 +358,71 @@ public function simpleDownload(string $userId, string $url, $resource, array $pa
}
}

/**
* Download content from a URL and save it as a new file in a folder.
* Handles resource management, timestamp setting, and cleanup on failure.
*
* @param string $userId
* @param Folder $saveFolder Target Nextcloud folder
* @param string $fileName Name of the file to create
* @param string $fileUrl URL to download from
* @param int|null $mtime Unix timestamp for the file modification time; uses current time if null
* @param array $params Additional HTTP query parameters
* @return int|null downloaded file size in bytes, or null on failure
*/
public function downloadAndSaveFile(
string $userId,
Folder $saveFolder,
string $fileName,
string $fileUrl,
?int $mtime = null,
array $params = [],
): ?int {
try {
$savedFile = $saveFolder->newFile($fileName);
} catch (NotPermittedException|InvalidPathException $e) {
return null;
}

try {
$resource = $savedFile->fopen('w');
} catch (LockedException $e) {
return null;
Comment thread
AhsanIsEpic marked this conversation as resolved.
}
if ($resource === false) {
Comment thread
AhsanIsEpic marked this conversation as resolved.
return null;
Comment thread
AhsanIsEpic marked this conversation as resolved.
}

$res = $this->simpleDownload($userId, $fileUrl, $resource, $params);
if (!isset($res['error'])) {
if (is_resource($resource)) {
fclose($resource);
}
if ($mtime !== null) {
$savedFile->touch($mtime);
} else {
$savedFile->touch();
}
$stat = $savedFile->stat();
return (int)($stat['size'] ?? 0);
} else {
if (is_resource($resource)) {
fclose($resource);
}
if ($savedFile->isDeletable()) {
try {
$savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
} catch (\Throwable $e) {
}
try {
$savedFile->delete();
} catch (\Throwable $e) {
}
}
}
return null;
}

private function checkTokenExpiration(string $userId): void {
$refreshToken = $this->secretService->getEncryptedUserValue($userId, 'refresh_token');
$expireAt = $this->userConfig->getValueInt($userId, Application::APP_ID, 'token_expires_at', lazy: true);
Expand Down
Loading
Loading