Table of Contents

Class PortalAccountProvider

Namespace
VpnHood.AppLib.Portal
Assembly
VpnHood.AppLib.Portal.dll

Account facade over the Portal API. One read (GET /account) answers the whole account — identity, THE one access code serving it and the store subscription behind it — because the portal ranks and chooses server-side; no device walks a list, and no backend id is ever on the wire.

public class PortalAccountProvider : IAccountProvider, IDisposable
Inheritance
PortalAccountProvider
Implements
Inherited Members

Constructors

PortalAccountProvider(IAuthenticationProvider, IBillingProvider?, Uri, string, bool)

Initializes a new instance of the PortalAccountProvider class.

public PortalAccountProvider(IAuthenticationProvider authenticationProvider, IBillingProvider? billingProvider, Uri portalBaseUrl, string packageName, bool ignoreSslVerification = false)

Parameters

authenticationProvider IAuthenticationProvider

The sign-in, whose credential goes with each authenticated call; this provider disposes it.

billingProvider IBillingProvider

The store this build sells through, or null where it sells nothing; this provider disposes it.

portalBaseUrl Uri

The portal's address.

packageName string

The app's package name, which the portal knows the app by.

ignoreSslVerification bool

true to accept any server certificate, as a development portal's.

Properties

AuthenticationProvider

Gets the sign-in: who is signed in, and the credential that proves it.

public IAuthenticationProvider AuthenticationProvider { get; }

Property Value

IAuthenticationProvider

Billing

Null when the app has no in-app purchasing.

public AppBilling? Billing { get; }

Property Value

AppBilling

Remarks

It is null where no billing provider is given; its order processor redeems purchases at the portal.

Methods

DeleteAccount(CancellationToken)

Delete the signed-in account everywhere: the backend erases the person, every device is signed out, and a later sign-in creates a brand-new account. Nothing blocks it — website billing is cancelled at the end of its paid period backend-side, and a store subscription is deliberately left untouched (signing in again brings it back by itself; only the person can cancel it, in their store). It never ends already-paid access. Mandatory, not optional: any provider with sign-in owes an in-app deletion path by store policy (Apple 5.1.1(v), Google Play).

The backend call only — no ui context, because there is nothing to show. Signing THIS device out belongs to the app, which does it after this returns, so a refusal leaves the session intact and the person can retry.

public Task DeleteAccount(CancellationToken cancellationToken)

Parameters

cancellationToken CancellationToken

The token to monitor for cancellation requests.

Returns

Task

A task that completes once the backend has deleted the account.

Exceptions

ApiException

The account backend refused the request.

OperationCanceledException

cancellationToken was canceled.

Dispose()

Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.

public void Dispose()

Remarks

It disposes the billing provider, the sign-in and its HTTP client.

GetAccount(CancellationToken)

The signed-in account, or null. AccessCodeInfo carries THE one code serving this account, whichever channel delivered it: the backend ranks everything the account holds and recomputes the winner on every read, so the app never sees a list and never has to pick. Subscription says whether a store subscription is what is paying right now. A displayed expiry is advisory; only an access-server refusal ends use of the credential.

public Task<Account?> GetAccount(CancellationToken cancellationToken)

Parameters

cancellationToken CancellationToken

The token to monitor for cancellation requests.

Returns

Task<Account>

A task whose result is the signed-in account, or null where nobody is signed in.

Remarks

Where nobody is signed in, it gives null without asking the portal. The portal's answer comes ranked; only whether this device can manage the subscription is worked out here, from the billing provider.

Exceptions

ApiException

The account backend refused the request.

OperationCanceledException

cancellationToken was canceled.

GetProductIds(CancellationToken)

The store product ids this app may sell — the backend's answer, because no store can list an app's own catalog: StoreKit and Play Billing only price ids they are given, so the ids must come from the side that redeems them. Provider prices exactly this list. Empty when nothing is sellable.

public Task<IReadOnlyList<string>> GetProductIds(CancellationToken cancellationToken)

Parameters

cancellationToken CancellationToken

The token to monitor for cancellation requests.

Returns

Task<IReadOnlyList<string>>

A task whose result is the product IDs.

Remarks

The sellable products according to the portal (GET /billing/products), which is where the mapping from a store product to a plan already lives: a product the portal does not map cannot be redeemed, so asking it — rather than trusting the build's own list — is what keeps a purchase from landing on a plan the backend has never heard of. Read anonymously: the resource takes no session, and a plans page renders before anyone signs in.

A portal that cannot answer is a failure, not an empty catalog, and it is deliberately not softened with the build's own ids: the payment sheet would still open, the store would still charge, and the proof would then have nowhere to be redeemed. The UI turns this into "the store is unavailable, try again", which is the only honest offer while the backend is down. An answered-but-empty catalog is a different thing and is honoured as given — the portal saying "nothing is sellable here" is an answer.

Exceptions

ApiException

The account backend refused the request.

InvalidOperationException

The app has no store, so it sells nothing.

OperationCanceledException

cancellationToken was canceled.

InvalidOperationException

The app has no store, so it sells nothing.

ApiException

The portal refused the request.

OperationCanceledException

cancellationToken was canceled.

ReportAccessCodeRejected(string, CancellationToken)

Tell the account that the access server REFUSED its current code — the only thing a device ever reports, and the whole of what the backend's ranking needs. Every code an account holds is either eligible or rejected: no expiry dates, no reasons, no observation times, and no successful-connection counterpart to this call.

The backend applies it only while this is still the account's CURRENT code, so a delayed refusal that has been overtaken by a different one changes nothing. It is recorded against THIS account alone — the same bearer code may be serving somebody else perfectly well — and against every entry holding that string, because identical codes are the same credential.

Rejection skips a code, it never deletes one, and it never skips a code being paid for right now: that one is still served, and the refusal is only recorded. Where every code the account holds has been refused, the account keeps serving the one refused last. Uploading the code again through SetAccessCode(string?, CancellationToken) clears its refusal, which is all that Retry needs to be — and the reason the one edge case here is survivable: remove-then-re-add of the same string can be caught by a late report, and one more retry undoes it.

The code travels in the authenticated request body and never in a URL.

public Task ReportAccessCodeRejected(string accessCode, CancellationToken cancellationToken)

Parameters

accessCode string

The code the access server refused.

cancellationToken CancellationToken

The token to monitor for cancellation requests.

Returns

Task

A task that represents the asynchronous operation.

Exceptions

ApiException

The account backend refused the request.

NotSupportedException

The provider keeps no codes on the account.

OperationCanceledException

cancellationToken was canceled.

SetAccessCode(string?, CancellationToken)

Upload a code the person typed into the account's ONE upload slot, or empty the slot with null. The uploaded code ranks above every code of the account that nobody is paying for, because typing a code is saying use this, and below anything being paid for right now.

The backend takes the code ON TRUST: it stores any string of valid access-code shape without proving that the code exists, because only the access server can say whether a code works and it says so at use time. There is therefore no not found answer to inspect.

Uploading transfers the code to the account, and the account does not hand it back when it is deleted. Every upload replaces the slot's code, a code the account already holds too, and clears a refusal reported for that code (ReportAccessCodeRejected(string, CancellationToken)).

Nothing here is ordered by time. Two devices that both upload are resolved by whichever call reaches the backend last, so a code typed during an outage can win over a later decision made elsewhere — accepted deliberately, because ordering it would cost a whole sync protocol to settle an argument almost nobody is having.

public Task SetAccessCode(string? accessCode, CancellationToken cancellationToken)

Parameters

accessCode string

The code to upload, or null to idempotently empty the slot.

cancellationToken CancellationToken

The token to monitor for cancellation requests.

Returns

Task

A task that represents the asynchronous operation.

Exceptions

ApiException

The account backend refused the request, as the portal refuses a string that is not shaped as a code.

NotSupportedException

The provider keeps no codes on the account.

OperationCanceledException

cancellationToken was canceled.