Skip to content

Repository files navigation

status: experimental Swift: 6 Platform: macOS 14+ Dependency: SwiftProtobuf License: MIT

FinskyKit

FinskyKit is a small Swift package for the Google Play / Finsky client flow: auth, Android device check-in, DFE bootstrap, app version lookup, entitlement checks, and APK delivery download.

Contents


Overview

FinskyKit wraps the minimum set of Play/Finsky calls needed to download APK delivery files for an app already owned by the signed-in account.

It can:

  • exchange a Google OAuth access token for Finsky auth material
  • generate and persist Android device state
  • perform Google Services Framework check-in
  • bootstrap DFE state with user settings, TOC, device config, and TOS handling
  • query the latest version code and version name for a package
  • check delivery entitlement before downloading
  • download base APK and split APK files
  • report per-file and total download progress

Lifecycle

A normal caller does this:

  1. Obtain a Google OAuth access token outside FinskyKit.
  2. Create FinskyClient.
  3. Call auth(accessToken:fallbackEmail:userID:).
  4. Store the returned FinskyCredential in the caller's credential store.
  5. Call latest(packageName:abi:credential:) when version information is needed.
  6. Optionally call checkDownloadAccess(...) before showing a download action.
  7. Call download(...) to write APK files to disk.

FinskyClient can persist Android device state separately from credentials. Pass deviceStateDirectory to keep check-in state stable across app launches. When the persisted state contains a complete DFE bootstrap, later calls reuse it instead of repeating the bootstrap, even if the in-memory FinskyCredential was loaded from a stale cache.


Installation

Swift Package Manager

dependencies: [
  .package(url: "https://github.com/hugonote/FinskyKit.git", from: "0.1.0")
]

Then add the product to your target:

.product(name: "FinskyKit", package: "FinskyKit")

Requirements


Quick Start

import FinskyKit
import Foundation

let client = FinskyClient(
    deviceStateDirectory: URL(fileURLWithPath: "/path/to/device-state")
)

let credential = try await client.auth(
    accessToken: googleOAuthAccessToken,
    fallbackEmail: "user@example.com",
    userID: "user@example.com"
)

let version = try await client.latest(
    packageName: "com.example.app",
    abi: "arm64-v8a",
    credential: credential
)

let apks = try await client.download(
    packageName: version.packageName,
    versionCode: version.versionCode,
    abi: version.abi,
    credential: credential,
    outputDirectory: URL(fileURLWithPath: "/tmp/apks")
) { progress in
    print("\(progress.completedFiles)/\(progress.totalFiles)", progress.fileName)
}

for apk in apks {
    print(apk.kind, apk.fileURL.path)
}

Recipes

Create a Client

let client = FinskyClient()

Use a state directory when the app should reuse the generated Android device:

let client = FinskyClient(
    deviceStateDirectory: appSupportURL.appendingPathComponent("FinskyDeviceState")
)

Authenticate

let credential = try await client.auth(
    accessToken: accessToken,
    fallbackEmail: email,
    userID: accountID
)

accessToken is a Google OAuth access token obtained by the host app. fallbackEmail is used when the auth response does not include an email. userID is the stable account key used for persisted device state.

It does not have to be a numeric Google account ID. FinskyKit uses it as a file key when deviceStateDirectory is enabled, so the same signed-in account should receive the same userID on every launch. A verified email address is a reasonable value when the host app has one. If the app supports multiple accounts, each account should use a different userID.

Query Latest Version

let version = try await client.latest(
    packageName: "com.example.app",
    abi: "arm64-v8a",
    credential: credential
)

The client reads version data from details, then bulk details, then delivery metadata when earlier responses are incomplete.

Check Entitlement

try await client.checkDownloadAccess(
    packageName: "com.example.app",
    versionCode: version.versionCode,
    abi: version.abi,
    credential: credential
)

Use this when UI code needs to distinguish "signed in" from "can download this package/version".

Download APK Delivery Files

let files = try await client.download(
    packageName: "com.example.app",
    versionCode: version.versionCode,
    abi: version.abi,
    credential: credential,
    outputDirectory: outputDirectory
) { event in
    let total = event.totalBytesExpected.map(String.init) ?? "unknown"
    print("\(event.totalBytesReceived) / \(total)")
}

The return value contains the downloaded base APK and split APKs. The base APK is returned first. If a download fails partway through, files written earlier in the same call are removed before the error propagates.

Store Credentials

FinskyCredential is Codable, so callers can encode it into their own storage system:

let data = try JSONEncoder().encode(credential)
let restored = try JSONDecoder().decode(FinskyCredential.self, from: data)

FinskyKit does not write credentials to Keychain or disk by itself.


Public API

FinskyClient

High-level client for auth, version lookup, entitlement checks, and downloads.

public init(
    deviceStateDirectory: URL? = nil,
    endpoints: FinskyEndpoints = .production
)
  • deviceStateDirectory: optional directory for persisted Android device state.
  • endpoints: production by default; override for tests or controlled fixtures.
public func auth(
    accessToken: String,
    fallbackEmail: String?,
    userID: String
) async throws -> FinskyCredential

Exchanges an OAuth token, checks in a device when needed, bootstraps DFE state, and returns a credential object.

public func importLegacyCredential(
    email: String,
    userID: String,
    masterToken: String
) async throws -> FinskyCredential

Accepts a previously exchanged Google master token, performs check-in and DFE bootstrap when needed, and returns a full credential. Use this when migrating credentials persisted by older versions that stored only the master token.

public func latest(
    packageName: String,
    abi: String,
    credential: FinskyCredential
) async throws -> FinskyVersion

Returns the latest version metadata visible to the account/device.

public func checkDownloadAccess(
    packageName: String,
    versionCode: Int,
    abi: String,
    credential: FinskyCredential
) async throws

Throws when delivery is unavailable or the account lacks entitlement.

public func download(
    packageName: String,
    versionCode: Int,
    abi: String,
    credential: FinskyCredential,
    outputDirectory: URL,
    progress: @Sendable @escaping (FinskyDownloadProgress) -> Void
) async throws -> [FinskyAPK]

Downloads delivery files to outputDirectory, decompressing gzipped delivery responses when needed.

FinskyDownloading

Protocol implemented by FinskyClient. Use it to inject fake clients in app tests.

public protocol FinskyDownloading: Sendable {
    func auth(
        accessToken: String,
        fallbackEmail: String?,
        userID: String
    ) async throws -> FinskyCredential

    func importLegacyCredential(
        email: String,
        userID: String,
        masterToken: String
    ) async throws -> FinskyCredential

    func latest(
        packageName: String,
        abi: String,
        credential: FinskyCredential
    ) async throws -> FinskyVersion

    func checkDownloadAccess(
        packageName: String,
        versionCode: Int,
        abi: String,
        credential: FinskyCredential
    ) async throws

    func download(
        packageName: String,
        versionCode: Int,
        abi: String,
        credential: FinskyCredential,
        outputDirectory: URL,
        progress: @Sendable @escaping (FinskyDownloadProgress) -> Void
    ) async throws -> [FinskyAPK]
}

importLegacyCredential(email:userID:masterToken:) accepts a previously exchanged Google master token and produces a fully prepared FinskyCredential. Use it when migrating credentials persisted by an older client that stored only the master token.

FinskyCredential

Credential returned by auth(...) and reused for later calls.

public struct FinskyCredential: Codable, Sendable, Equatable {
    public var email: String?
    public var userID: String
    public var masterToken: String
    public var authCookie: String
}

The struct also carries internal device state. That state is intentionally not part of the public member list.

FinskyVersion

Version metadata returned by latest(...).

public struct FinskyVersion: Codable, Sendable, Equatable {
    public var packageName: String
    public var versionCode: Int
    public var versionName: String?
    public var abi: String
}

FinskyAPK

Description of a downloaded APK file.

public struct FinskyAPK: Codable, Sendable, Equatable {
    public enum Kind: String, Codable, Sendable {
        case base
        case split
    }

    public var kind: Kind
    public var splitName: String?
    public var fileName: String
    public var fileURL: URL
    public var sizeBytes: Int64
    public var sha256: String?
}

FinskyDownloadProgress

Progress event emitted during download(...).

public struct FinskyDownloadProgress: Sendable, Equatable {
    public var packageName: String
    public var fileName: String
    public var bytesReceived: Int64
    public var totalBytesReceived: Int64
    public var totalBytesExpected: Int64?
    public var completedFiles: Int
    public var totalFiles: Int
    public var speedBytesPerSecond: Double?
    public var etaSeconds: Double?
}

FinskyEndpoints

Endpoint set used by the client.

public struct FinskyEndpoints: Sendable, Equatable {
    public var authURL: URL
    public var checkinURL: URL
    public var userSettingsURL: URL
    public var tocURL: URL
    public var uploadDeviceConfigURL: URL
    public var acceptTosURL: URL
    public var detailsURL: URL
    public var bulkDetailsURL: URL
    public var deliveryURL: URL

    public static let production: FinskyEndpoints
}

Most callers should use .production.


Errors

FinskyKit throws FinskyError for typed failures:

public enum FinskyError: Error, Sendable, Equatable {
    case invalidURL(String)
    case invalidAuthResponse(String)
    case httpStatus(code: Int, body: String?)
    case notOwned(packageName: String)
    case notPurchased(packageName: String)
    case noEntitlement(packageName: String)
    case unavailable(packageName: String, message: String)
    case versionCodeMissing(String)
    case invalidResponse(String)
}

The enum conforms to LocalizedError, so UI code can use error.localizedDescription.

Entitlement-related HTTP bodies are mapped to:

  • notOwned(packageName:)
  • notPurchased(packageName:)
  • noEntitlement(packageName:)

Delivery responses with status 2 and no app delivery data are mapped to noEntitlement(packageName:).


Limitations

  • Unofficial Google Play/Finsky API surface.
  • No public Play Store search or browsing API.
  • No purchase or billing flow.
  • No credential persistence beyond Codable DTOs.
  • No Keychain integration.
  • Protobuf models include only the fields this package needs.
  • Delivery requires an account that is already entitled to the requested app.
  • Runtime target is macOS 14+.

Development

Run tests:

swift test

The live smoke test is disabled by default. It runs only when these environment variables are set:

  • FINSKY_LIVE_SMOKE=1
  • FINSKY_ACCESS_TOKEN: Google OAuth access token
  • FINSKY_USER_ID: stable account/device-state key, usually the same value you pass as userID to auth(...)

Optional variables:

  • FINSKY_PACKAGE_NAME: package to query; defaults to the test fixture package
  • FINSKY_ABI: preferred ABI; defaults to arm64-v8a
  • FINSKY_EMAIL: fallback email passed to auth

Example:

FINSKY_LIVE_SMOKE=1 \
FINSKY_ACCESS_TOKEN="..." \
FINSKY_USER_ID="user@example.com" \
FINSKY_PACKAGE_NAME="com.example.app" \
swift test

About

Swift package for Google Play/Finsky client flows and APK delivery

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages