Initially I wanted to setup Google Photos API via Java Library on Android, but I bump into 2 main issues
- OAuth Secret will be exposed on Android, which is a security risk
google-photos-library-client
usesprotobuf-java
which conflictprotobuf-lite
used by Firestore, and excluding either one causes issues with its respective library. This is officially a Java library (not Android library).
At the end, I decide to access Google Photos API via Python on Cloud Functions. Although only Java and PHP has an official Google Photos library, but it is quite easy to access Google Photos REST API via google-api-python-client
.
Caveats
- No official Android/iOS/Web client library, so you need to call the REST API on backend server
- It is impossible to get the location of a photo via API, though Google has access to the location information. It used to possible to get such information via Google Drive API, but Google Photos and Google Drive is separated since July 2019.
- All media items uploaded to Google Photos using the API are stored in full resolution at original quality. They count towards the user’s storage. It is impossible to upload in High Quality (with Unlimited free storage)
- Quota limit of 10,000 requests per project per day, more than that you need to apply for partner program.
- Need to adhere to some UX guidelines and Acceptable use policy (you can't build a general purpose photo gallery app, cannot store photos on server, commercial printing service require a commercial license, etc.)
- The API doesn't expose the labels of a photo (check if this photo is a cat, person or mushroom), though you can query based on broad catagories (you can search for pets photos, but not cat specifically)
- The url to the Photo expire after 60 minutes, after which you need to query the media again for a new url.
Enable Google Photos API
https://console.cloud.google.com/apis/library/photoslibrary.googleapis.com
OAuth Client Id and Secret
Goto Google Cloud Console -> Credentials
Create credentials -> OAuth Client Id
- Application type: Web application
- Name
Download json as oauth2-secret.json
for use on Python Cloud Functions.
Copy the Client Id for use on Android.
NOTE: This JSON file is security sensitive (store securely on server, never stored on client (e.g. Android))
Python Cloud Functions
import loggingfrom flask import jsonifyfrom googleapiclient.discovery import buildfrom google_auth_oauthlib.flow import Flow# from google.auth.transport.requests import Requestlog = ...def find_google_photos(request): # userinfo.profile openid required due to # Warning: Scope has changed from "https://www.googleapis.com/auth/photoslibrary.readonly" to "https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/photoslibrary.readonly" # alternative solution: https://stackoverflow.com/a/51643134/561259 scopes = [ 'https://www.googleapis.com/auth/userinfo.profile', 'openid', 'https://www.googleapis.com/auth/photoslibrary.readonly' ] if not request.is_json: json_abort(400, message="Invalid request (json)") data = request.json.get('data') if not data: json_abort(400, message="Invalid request (data)") if 'auth_code' not in data: json_abort(400, message="Missing auth_code") # https://google-auth-oauthlib.readthedocs.io/en/latest/reference/google_auth_oauthlib.flow.html flow = Flow.from_client_secrets_file( 'keys/oauth2-secret.json', scopes=scopes) # if same auth_code is used again # oauthlib.oauth2.rfc6749.errors.InvalidGrantError: (invalid_grant) Bad Request flow.fetch_token(code=data['auth_code']) creds = flow.credentials # https://github.com/googleapis/google-api-python-client/issues/299 # silence "file_cache is unavailable when using oauth2client >= 4.0.0 or google-auth" with cache_discovery=False service = build('photoslibrary', 'v1', credentials=creds, cache_discovery=False) results = service.mediaItems().list( pageSize=10 ).execute() items = results.get('mediaItems', []) if not items: log.info('No items') else: for item in items: log.info(item['filename']) # log.info(json.dumps(item, indent=2)) data = { 'media_items': items } return jsonify({ 'data': data })
NOTE: Secure Cloud Functions With Firebase Authentication (Python)
NOTE: auth_code
can only be used once with Flow.fetch_token
, where the same auth_code
used on second time will trigger InvalidGrantError: (invalid_grant) Bad Request
. Therefore, it might be necessary to store the Credentials object for reuse.
Android
Google Sign In and get serverAuthCode
dependencies {
implementation 'com.google.android.gms:play-services-auth:17.0.0'
// Optional
implementation 'com.google.firebase:firebase-functions:19.0.1'
}
class TestFragment : Fragment() { companion object { const val REQUEST_GOOGLE_PHOTOS_SIGN_IN = 1 } fun signIn() { val serverClientId = ... // from Google Cloud Console -> Credentials -> OAuth Client Id val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) .requestScopes(Scope("https://www.googleapis.com/auth/photoslibrary.readonly")) .requestServerAuthCode(serverClientId) .build() val client = GoogleSignIn.getClient(context!!, gso) startActivityForResult(client.signInIntent, REQUEST_GOOGLE_PHOTOS_SIGN_IN) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == REQUEST_GOOGLE_PHOTOS_SIGN_IN) { if (resultCode == RESULT_OK) { GoogleSignIn.getSignedInAccountFromIntent(data) .addOnSuccessListener { account -> val authCode = account.serverAuthCode HttpFunctions.instance.findGooglePhotos(authCode!!) .addOnSuccessListener { Timber.d(it) } .addOnFailureListener { e -> Timber.e("findGooglePhotos: $e", e) } } .addOnFailureListener { e -> Timber.e("REQUEST_GOOGLE_PHOTOS_SIGN_IN fail", e) } } } }}
Call Python Cloud Functions via firebase-functions library
class HttpFunctions { @Serializable class FindPlaceResult(val places: List<Place>) { @Serializable class Place(@SerialName("place_id") val placeId: String, val name: String, val types: List<String>) } companion object { val instance: HttpFunctions by lazy { HttpFunctions() } } private val functions by lazy { FirebaseFunctions.getInstance() } fun findGooglePhotos(authCode: String): Task<String> { val data = mapOf( "auth_code" to authCode ) return functions.getHttpsCallable("find_google_photos") .call(data) .continueWith { task -> @Suppress("UNCHECKED_CAST") val result = task.result?.data as Map<String, Any> // TODO: convert to actual object rather than return json string JSONObject(result).toString() } }}
References: