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.
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
A normal caller does this:
- Obtain a Google OAuth access token outside FinskyKit.
- Create
FinskyClient. - Call
auth(accessToken:fallbackEmail:userID:). - Store the returned
FinskyCredentialin the caller's credential store. - Call
latest(packageName:abi:credential:)when version information is needed. - Optionally call
checkDownloadAccess(...)before showing a download action. - 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.
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")- macOS 14+
- Swift 6
SwiftProtobuf
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)
}let client = FinskyClient()Use a state directory when the app should reuse the generated Android device:
let client = FinskyClient(
deviceStateDirectory: appSupportURL.appendingPathComponent("FinskyDeviceState")
)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.
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.
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".
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.
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.
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 -> FinskyCredentialExchanges 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 -> FinskyCredentialAccepts 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 -> FinskyVersionReturns the latest version metadata visible to the account/device.
public func checkDownloadAccess(
packageName: String,
versionCode: Int,
abi: String,
credential: FinskyCredential
) async throwsThrows 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.
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.
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.
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
}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?
}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?
}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.
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:).
- Unofficial Google Play/Finsky API surface.
- No public Play Store search or browsing API.
- No purchase or billing flow.
- No credential persistence beyond
CodableDTOs. - 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+.
Run tests:
swift testThe live smoke test is disabled by default. It runs only when these environment variables are set:
FINSKY_LIVE_SMOKE=1FINSKY_ACCESS_TOKEN: Google OAuth access tokenFINSKY_USER_ID: stable account/device-state key, usually the same value you pass asuserIDtoauth(...)
Optional variables:
FINSKY_PACKAGE_NAME: package to query; defaults to the test fixture packageFINSKY_ABI: preferred ABI; defaults toarm64-v8aFINSKY_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