Android Access Google Photos API via Python REST

October 15, 2019
Create credentials using Google AOuth2 from auth code

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 uses protobuf-java which conflict protobuf-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 logging

from flask import jsonify

from googleapiclient.discovery import build
from google_auth_oauthlib.flow import Flow
# from google.auth.transport.requests import Request

log = ...

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:

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.