Skip to content
29 changes: 27 additions & 2 deletions app/Devnull/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ final class Account

private const string TimezoneKey = 'devnull.timezone';

private const string TermsKey = 'devnull.terms-to-accept';

public function user(): ?User
{
$attributes = Cache::get(self::CacheKey);
Expand Down Expand Up @@ -191,8 +193,30 @@ public function rememberTimezone(?string $timezone): void
}

/**
* Signs this phone out: who it was, the badges, its pastéis and Remotes, your timezone, every answer, image and
* voice note kept for a quick start, and your unsent drafts and recordings.
* The version of the terms devnull last said you have yet to accept, so the app opens on them (see AcceptTerms),
* or null when you're up to date.
*/
public function termsToAccept(): ?string
{
$version = Cache::get(self::TermsKey);

return is_string($version) && $version !== '' ? $version : null;
}

public function rememberTermsToAccept(?string $version): void
{
if ($version === null) {
Cache::forget(self::TermsKey);

return;
}

Cache::forever(self::TermsKey, $version);
}

/**
* Signs this phone out: who it was, the badges, its pastéis and Remotes, your timezone, the terms you owe, every
* answer, image and voice note kept for a quick start, and your unsent drafts and recordings.
*/
public function forget(): void
{
Expand All @@ -209,5 +233,6 @@ public function forget(): void
Cache::forget(self::ProfileKey);
Cache::forget(self::PinWarningKey);
Cache::forget(self::TimezoneKey);
Cache::forget(self::TermsKey);
}
}
29 changes: 28 additions & 1 deletion app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@

public const string SettingsPath = 'me/settings';

/**
* Where the signed-in account accepts the terms and privacy policy in force.
*/
public const string TermsPath = 'me/terms';

/**
* The commits the signed-in account scheduled, as the web's Scheduled
* commits page lists them.
Expand Down Expand Up @@ -184,23 +189,32 @@ public static function pastelPath(string $id): string
* Trades the code dev-null.sh sent back, with the PKCE verifier only this
* phone knows, for the user it approved this phone for. It's signed like
* every request, so the code only works for the key it was made for.
*
* No sign-in starts until the terms are accepted, so this says they were.
* devnull works out which version that means from when it hears.
*/
public function redeemSignIn(string $code, #[SensitiveParameter] string $verifier): User
{
return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: ['code' => $code, 'code_verifier' => $verifier])));
return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: [
'code' => $code,
'code_verifier' => $verifier,
'terms_accepted' => true,
])));
}

/**
* Signs in on a phone devnull doesn't know yet with an Apple ID linked to
* an account, which approves this phone's key. Apple's identity token
* carries sha256 of the nonce, and devnull checks it against the raw one.
* It says the terms were accepted, as redeemSignIn() does.
*/
public function signInWithApple(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce, string $deviceName): User
{
return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: [
'apple_identity_token' => $identityToken,
'nonce' => $nonce,
'device_name' => $deviceName,
'terms_accepted' => true,
])));
}

Expand Down Expand Up @@ -232,6 +246,19 @@ public function me(): CurrentUser
return CurrentUser::fromArray($this->get('me'));
}

/**
* Accepts the terms and privacy policy, naming the version devnull said is in force. When they changed again
* since, devnull refuses with a 422 rather than take a yes to a version nobody showed you. It answers as
* `GET /me` does, and every kept copy of you takes it.
*/
public function acceptTerms(string $version): CurrentUser
{
$json = $this->json($this->send('PUT', self::TermsPath, payload: ['version' => $version]));
$this->cache->meChanged($json);

return CurrentUser::fromArray($json);
}

/**
* Deletes the account this phone is signed in as, the way the web's Delete
* account does. devnull deletes its devices with it, so this phone's key
Expand Down
26 changes: 26 additions & 0 deletions app/Devnull/Data/CurrentUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
* it only was when you signed in). It has whether pastéis mode is on, how
* many pastéis you can still give today, and how many others have given you,
* and your Remotes settings, or null while you have Remotes off.
*
* And the terms: the version in force, which is the moment it took effect, when
* you last accepted them, and whether that was this version. A devnull too old
* to say leaves all three null.
*/
final readonly class CurrentUser
{
Expand All @@ -30,6 +34,9 @@ public function __construct(
public Pasteis $pasteisToday = new Pasteis,
public int $pasteisReceived = 0,
public ?Remotes $remotes = null,
public ?string $termsVersion = null,
public ?CarbonImmutable $termsAcceptedAt = null,
public ?bool $termsAccepted = null,
) {}

/**
Expand Down Expand Up @@ -59,9 +66,28 @@ public static function fromArray(array $json): self
pasteisReceived: is_int($data['pasteis_received'] ?? null) ? max(0, $data['pasteis_received']) : 0,
// Only devnull saying null means they're off: a devnull too old to say has them on, as everyone starts.
remotes: array_key_exists('remotes', $data) ? Remotes::fromArray($data['remotes']) : new Remotes,
termsVersion: is_string($data['terms_version'] ?? null) && $data['terms_version'] !== '' ? $data['terms_version'] : null,
termsAcceptedAt: Commit::date($data['terms_accepted_at'] ?? null),
termsAccepted: is_bool($data['terms_accepted'] ?? null) ? $data['terms_accepted'] : null,
);
}

/**
* The version of the terms devnull is waiting for you to accept, exactly as it sent it, or null when you have.
*/
public function termsToAccept(): ?string
{
return $this->termsAccepted === false ? $this->termsVersion : null;
}

/**
* When the terms in force took effect.
*/
public function termsTookEffect(): ?CarbonImmutable
{
return Commit::date($this->termsVersion);
}

/**
* You as this phone remembers you, until devnull says the rest.
*/
Expand Down
8 changes: 8 additions & 0 deletions app/Devnull/Display.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ public static function fullDateTime(CarbonInterface $date, ?string $timezone = n
return CarbonImmutable::instance($date)->setTimezone($timezone ?? app(Timezone::class)->yours())->format('g:i A · M j, Y');
}

/**
* The day something happened, with no time: "Nov 1, 2026", in your timezone.
*/
public static function day(CarbonInterface $date, ?string $timezone = null): string
{
return CarbonImmutable::instance($date)->setTimezone($timezone ?? app(Timezone::class)->yours())->format('M j, Y');
}

/**
* When a scheduled commit is due, as the web's Scheduled commits page
* prints it: "Oct 16, 2026, 10:30 AM", in the phone's timezone.
Expand Down
161 changes: 161 additions & 0 deletions app/NativeComponents/AcceptTerms.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

namespace App\NativeComponents;

use App\Devnull\Account;
use App\Devnull\Client;
use App\Devnull\Data\Commit;
use App\Devnull\Data\CurrentUser;
use App\Devnull\Display;
use App\Devnull\Exceptions\ApiException;
use App\Devnull\Exceptions\KeychainUnavailable;
use App\Devnull\Exceptions\Unauthenticated;
use App\NativeComponents\Concerns\LeavesDevnull;
use App\NativeComponents\Concerns\OpensLegalPages;
use App\NativeComponents\Concerns\RefreshesInBackground;
use App\NativeComponents\Concerns\TalksToDevnull;
use App\NativeComponents\Layouts\TabsLayout;
use Illuminate\View\View;
use Native\Mobile\Attributes\On;
use Native\Mobile\Edge\NativeComponent;
use Native\Mobile\Events\Alert\ButtonPressed;

/**
* Stands in front of the app until you accept the terms and privacy policy in force, as the web's terms dialog does.
* The app opens on it when devnull last said you owe them, and any screen that hears so from devnull sends you here
* at once (see RefreshesInBackground::takeTerms()). It says when they changed, or, when you never accepted any, only
* asks you to.
*
* Accepting names the version devnull said, so terms that change again while this is open aren't accepted unseen:
* devnull refuses, and the screen asks again for the new ones. The ways out are signing out and deleting the
* account. It asks devnull again as it opens, so terms accepted on the web since let you straight through.
*/
class AcceptTerms extends NativeComponent
{
use LeavesDevnull;
use OpensLegalPages;
use RefreshesInBackground;
use TalksToDevnull;

public const string Path = '/accept-terms';

public const string ChangedAgain = 'The terms changed again while this was open. Read them, then accept them.';

/**
* The version devnull is waiting for you to accept, exactly as it said it.
*/
public ?string $version = null;

/**
* Whether you accepted an earlier version, so these are terms that changed rather than ones you never saw.
*/
public bool $acceptedBefore = false;

public function mount(): void
{
$this->version = app(Account::class)->termsToAccept();

if ($this->version === null) {
$this->replace('/log');

return;
}

$kept = $this->cached('me');
$this->acceptedBefore = $kept !== null && CurrentUser::fromArray($kept)->termsAcceptedAt !== null;

$this->revalidate();
}

protected function revalidate(): void
{
$this->refresh('me', [], fn (array $json) => $this->takeTerms(CurrentUser::fromArray($json)), quiet: true);
}

protected function showsContent(): bool
{
return true;
}

public function accept(): void
{
if ($this->version === null) {
return;
}

try {
$current = app(Client::class)->acceptTerms($this->version);
} catch (Unauthenticated $exception) {
$this->signOut($exception);

return;
} catch (ApiException $exception) {
if ($exception->status !== 422) {
$this->error = $exception->firstError();

return;
}

// The version in force moved on since devnull said which, so this asks it for the new one.
$this->error = self::ChangedAgain;
$this->retry();

return;
} catch (KeychainUnavailable $exception) {
$this->error = $exception->getMessage();

return;
}

$this->error = null;
$this->takeTerms($current);
}

#[On(ButtonPressed::class)]
public function onAlertButton(string $label = '', ?string $id = null): void
{
if ($id === self::SignOutAlert && $label === 'Sign out') {
$this->forget();
}

if ($id === self::DeleteAccountAlert && $label === 'Delete') {
$this->deleteAccount();
}
}

public function render(): View
{
$tookEffect = Commit::date($this->version);

return view('native.accept-terms', [
'logo' => TabsLayout::logo(),
'title' => $this->acceptedBefore ? 'We updated our terms' : 'Accept our terms',
'explanation' => $this->acceptedBefore && $tookEffect !== null
? 'Our terms of service changed on '.Display::day($tookEffect).'. Read them, and accept them to keep using /dev/null.'
: 'Read the terms of service and privacy policy, and accept them to keep using /dev/null.',
]);
}

/**
* Takes devnull's word on the terms: on to the log once you're up to date with them, as after accepting them
* here or on the web, or else the version it's waiting for now.
*/
protected function takeTerms(CurrentUser $current): void
{
if ($current->termsAccepted === null || $current->user->username === '') {
return;
}

app(Account::class)->remember($current->user);
app(Account::class)->rememberTermsToAccept($current->termsToAccept());

if ($current->termsToAccept() === null) {
$this->replace('/log');

return;
}

$this->version = $current->termsToAccept();
$this->acceptedBefore = $current->termsAcceptedAt !== null;
}
}
Loading
Loading