Activities and Intents
The core programming model of this framework is exactly what
Android calls Activity and Intent. Every "screen" or "feature"
of the bot is a BotActivity; navigation between them happens via
Intent.
Creating an Activity
use Aicrion\Tandroid\Activity\BotActivity; use Aicrion\Tandroid\Activity\NavigationRequest; use Aicrion\Tandroid\Attribute\IntentFilter; use Aicrion\Tandroid\Intent\Intent; use Aicrion\Tandroid\View\View; #[IntentFilter(action: 'MAIN', category: 'LAUNCHER')] final class StartActivity extends BotActivity { public function onCreate(Intent $intent): ?NavigationRequest { $this->setContentView(View::message('Welcome 👋')); return null; } }
Every Activity must implement onCreate(). Four other hooks are
optional and default to a no-op implementation: onResume(),
onNewIntent(), onPause(), onDestroy().
Lifecycle
| Hook | When it's called | Return type |
|---|---|---|
onCreate(Intent $intent) |
First entry into this Activity | ?NavigationRequest (must be implemented) |
onNewIntent(Intent $intent) |
This Activity is already on top of the stack and receives a new callback_data (e.g. toggling a checkbox) — without a full onCreate cycle |
?NavigationRequest |
onResume() |
After onCreate/onNewIntent, and also when the user navigates back to this Activity via the Back button |
?NavigationRequest |
onPause() |
Right before another Activity is pushed onto the stack | void |
onDestroy() |
When this Activity is popped off the stack | void |
Key point: since PHP doesn't keep state between requests, every Activity is freshly instantiated on every request — exactly like Android recreating an Activity on a configuration change (screen rotation). If you need state that persists across Activities, use a ViewModel, not regular class properties.
Navigation Chains with NavigationRequest
BotActivity exposes two protected methods to subclasses:
protected function startActivity(Intent $intent): NavigationRequest; protected function finishWithResult(array $result = []): NavigationRequest;
When one of the lifecycle hooks (onCreate, onNewIntent,
onResume) returns a NavigationRequest (instead of null),
ActivityManager immediately follows it — within the same
incoming Update, with no extra round-trip to Telegram:
final class GatewayActivity extends BotActivity { public function onCreate(Intent $intent): ?NavigationRequest { if ($this->userIsAdmin()) { return $this->startActivity(Intent::to(AdminPanelActivity::class)); } $this->setContentView(View::message('Access restricted.')); return null; } }
If finishWithResult() is returned, ActivityManager behaves like
pressing the Back button: it pops the current Activity off the
stack, restores the previous Activity, and re-runs its onResume().
The chain depth is capped at 8 (
ActivityManager::MAX_CHAIN_DEPTH). If aViewisn't reached within that limit, aRuntimeExceptionis thrown — a safeguard against infinite navigation loops.
Intent — Explicit vs. Implicit
Intent\Intent has the exact same two modes as
android.content.Intent:
// Explicit: directly targets an Activity Intent::to(ProfileActivity::class); // Implicit: matched against IntentFilters by action/category Intent::action('VIEW_PROFILE');
Adding extras is fluent and immutable — every putExtra() returns a
new instance:
$intent = Intent::to(OrderActivity::class) ->putExtra('order_id', 42) ->withFlag(IntentFlag::ClearBackStack);
Navigation Flags (IntentFlag)
| Flag | Effect |
|---|---|
ClearBackStack |
Clears the user's back stack before pushing the new entry |
NewTask |
Behaves like ClearBackStack (both reset the stack) |
NoHistory |
This entry is never pushed onto the stack at all (a transient Activity) |
SingleTop |
Even if this exact Activity is already on top of the stack, a full onCreate runs instead of onNewIntent |
IntentFilter — How an Update Reaches an Activity
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] final class IntentFilter { public function __construct( public readonly string $action, public readonly ?string $category = null, public readonly ?string $pattern = null, public readonly int $priority = 0, ) {} }
Kernel\IntentResolver::resolve() converts an incoming Update into
an Intent like this:
- If
callback_datais valid JSON with anakey (i.e. it came from aWidget\Button::action()), an explicit Intent is built targeting that same Activity, andp(payload) is added as extras. - Otherwise, every registered
#[IntentFilter]is checked: if a filter has apattern, the message text is matched against that regex; otherwise, only filters withaction: 'MAIN'match regular messages. If multiple Activities match, the one with the highestprioritywins. - If no Activity matches, the Intent is routed to
Kernel\FallbackActivityMarker(the built-in "404 Activity").
#[IntentFilter(action: 'RELAY_TO_SPECIALIST', pattern: '/^\/ask /')] final class RelayActivity extends BotActivity { /* ... */ }
Back Stack
Kernel\BackStackStore keeps each user's stack (keyed by chat_id)
in cache (Redis or filesystem). Every time an Activity finishes
running successfully (and the navigation chain completes), a new
Activity\BackStackEntry is pushed — unless the Intent has the
NoHistory flag.
Activities can indirectly request to go back:
return $this->finishWithResult(['choice' => 'confirmed']);
Deep Links — Direct Entry with a /start Parameter
Kernel\DeepLinkResolver converts a /start <payload> parameter
(like t.me/YourBot?start=order:id=42) into an explicit Intent:
$resolver = new DeepLinkResolver(); $resolver->registerRoute('order', OrderActivity::class); $payload = DeepLinkResolver::extractStartPayload($update->text); // "order:id=42" $intent = $resolver->resolve($payload); // Intent::to(OrderActivity::class)->putExtra('id', '42')
The payload format is route:key1=val1,key2=val2.