Current section
Files
Jump to
Current section
Files
lib/live_react_native/mobile_channel.ex
defmodule LiveReactNative.MobileChannel do
@moduledoc """
Phoenix Channel implementation for mobile-native React Native clients.
This channel acts as a bridge between mobile clients using `createMobileClient()`
and existing Phoenix LiveView processes. It allows mobile apps to reuse existing
LiveView business logic without any server-side changes.
## Architecture
```
Mobile Client (createMobileClient)
↓ Phoenix Channel
LiveReactNative.MobileChannel
↓ Event Forwarding
Phoenix LiveView Process (unchanged)
```
## Usage
Mobile clients connect to this channel using topics like `mobile:/live/counter`
which map to LiveView routes like `/live/counter`.
## Authentication
Mobile clients authenticate using JWT tokens and user IDs rather than
browser sessions:
```javascript
const client = createMobileClient({
url: 'ws://localhost:4000/mobile',
params: { user_id: 'user123', token: 'jwt_token' }
});
```
"""
use Phoenix.Channel
require Logger
# Registry no longer needed in Channel-as-Process model - keeping for potential future use
# @registry LiveReactNative.MobileChannel.Registry
def join("mobile:" <> live_path, params, socket) do
Logger.info("Mobile channel join attempt: #{live_path}")
Logger.debug("Join params: #{inspect(params)}")
# NEW: Parse URL parameters from path (TDD Implementation)
{clean_path, query_params} = parse_url_parameters(live_path)
Logger.debug("Parsed clean_path: #{clean_path}, query_params: #{inspect(query_params)}")
with {:ok, authenticated_socket} <- authenticate_mobile_user(socket, params),
{:ok, live_view_module, session_info, route_info} <- resolve_live_view_module_with_route(clean_path, Map.put(params, "socket", authenticated_socket)),
{:ok, auth_validated_socket} <- execute_on_mount_hooks(session_info, authenticated_socket, params) do
# NEW: Extract path parameters from route pattern
path_params = extract_path_params_from_route_info(route_info, clean_path)
Logger.debug("Extracted path_params: #{inspect(path_params)}")
# NEW: Combine all parameters with proper precedence
url_params = combine_all_params(params, path_params, query_params)
Logger.debug("Combined url_params: #{inspect(url_params)}")
# NEW: Mount with handle_params/3 support
case mount_with_params(live_view_module, params, url_params, live_path, auth_validated_socket) do
{:ok, initial_assigns, initial_commands} ->
# Set up the socket with LiveView state (Channel-as-Process model)
mobile_params = socket.assigns[:mobile_params] || %{}
socket = auth_validated_socket
|> assign(:live_view_module, live_view_module)
|> assign(:live_path, live_path)
|> assign(:live_assigns, initial_assigns) # Store LiveView state in channel!
|> assign(:user_id, mobile_params["user_id"])
Logger.info("Mobile channel joined successfully: #{live_path}")
# Send assigns + initial commands together (synchronized)
{:ok, %{assigns: initial_assigns, commands: initial_commands}, socket}
{:error, reason} ->
Logger.warning("Mobile channel mount_with_params failed: #{inspect(reason)}")
{:error, %{reason: reason}}
end
else
{:error, reason} ->
Logger.warning("Mobile channel join failed: #{inspect(reason)}")
{:error, %{reason: reason}}
end
end
def join(topic, _params, _socket) do
Logger.warning("Invalid mobile channel topic: #{topic}")
{:error, %{reason: "invalid_topic"}}
end
def handle_in("navigate:" <> new_path, params, socket) do
Logger.info("Mobile navigation request: #{new_path}")
current_path = socket.assigns[:live_path]
# Get router from endpoint (ServerWeb.Endpoint -> ServerWeb.Router)
web_module = socket.endpoint |> Module.split() |> List.first()
router = Module.concat([web_module, "Router"])
# Check if navigation crosses live_session boundaries
case check_live_session_boundary_impl(current_path, new_path, router) do
:same_session ->
# Safe to navigate within same session - handle mobile navigation
handle_mobile_navigation(new_path, params, socket)
:different_session ->
# Force reconnection (same as web LiveView does)
Logger.info("Navigation crosses live_session boundary: #{current_path} -> #{new_path}")
push(socket, "session_boundary_crossed", %{
redirect_to: new_path,
reason: "live_session_boundary_crossed"
})
{:noreply, socket}
:error ->
Logger.warning("Invalid route for navigation: #{new_path}")
{:reply, {:error, %{reason: "invalid_route"}}, socket}
end
end
def handle_in("event:" <> event_name, payload, socket) do
Logger.debug("Mobile channel event: #{event_name} with payload: #{inspect(payload)}")
# Phase 2: Direct LiveView.handle_event/3 calls with Channel-as-Process model
handle_live_view_event(event_name, payload, socket)
end
def handle_in(event, payload, socket) do
Logger.debug("Mobile channel unhandled event: #{event} with payload: #{inspect(payload)}")
{:reply, {:error, %{reason: "unknown_event_type", event: event}}, socket}
end
# Phase 3: Handle external messages (PubSub, timers, etc.) by forwarding to LiveView
def handle_info(msg, socket) do
Logger.debug("Mobile channel received message: #{inspect(msg)}")
# Phase 3: Forward messages to LiveView.handle_info/2 function
handle_live_view_info(msg, socket)
end
# Note: We no longer use separate PubSub for RN commands or assigns updates
# Everything is sent together in event responses for perfect synchronization
def terminate(reason, _socket) do
Logger.info("Mobile channel terminating: #{inspect(reason)}")
# In Channel-as-Process model, no separate processes to clean up
# LiveView state was stored in channel socket, which is automatically cleaned up
:ok
end
# Private functions
defp authenticate_mobile_user(socket, _params) do
# Trust socket-level authentication - if socket connected, user is authenticated
# The MobileSocket.connect/3 should handle authentication validation
# Get mobile_params from socket assigns (Phoenix automatically transfers connection assigns to channel)
mobile_params = socket.assigns[:mobile_params] || %{}
user_id = mobile_params["user_id"]
if user_id do
socket = socket
|> assign(:mobile_params, mobile_params) # Transfer mobile_params to channel socket
|> assign(:authenticated_user, %{user_id: user_id})
|> assign(:mobile_user_id, user_id)
{:ok, socket}
else
{:error, "no_user_id"}
end
end
# Note: JWT validation should be implemented in MobileSocket.connect/3
# This keeps the library agnostic while allowing each app to implement
# their own authentication logic (JWT, sessions, API keys, etc.)
@doc """
Execute on_mount hooks extracted from live_session - this is CRITICAL for security!
This ensures mobile clients go through the SAME authentication pipeline as web clients,
respecting live_session boundaries and on_mount hook requirements.
"""
def execute_on_mount_hooks(session_info, socket, params) do
hooks = session_info[:on_mount] || []
if Enum.empty?(hooks) do
# No hooks to execute, continue with current socket
{:ok, socket}
else
# Create a LiveView-compatible socket for hook execution
live_socket = build_liveview_socket_for_auth(socket, params)
# Execute hooks in sequence (same as LiveView does)
case run_on_mount_hooks_sequence(hooks, live_socket) do
{:cont, updated_live_socket} ->
# Merge auth results back to channel socket
updated_socket = merge_auth_results_to_channel_socket(socket, updated_live_socket)
{:ok, updated_socket}
{:halt, _updated_live_socket} ->
{:error, "on_mount_hook_rejected"}
end
end
end
defp build_liveview_socket_for_auth(channel_socket, params) do
# Build a minimal LiveView-compatible socket for auth hooks
# This socket structure is what on_mount hooks expect
endpoint_module = channel_socket.endpoint |> Module.split() |> Enum.take(2) |> Module.concat()
router = Module.concat(endpoint_module, "Router")
%Phoenix.LiveView.Socket{
endpoint: channel_socket.endpoint,
router: router,
assigns: %{
# Copy mobile auth context
current_user: channel_socket.assigns[:authenticated_user],
mobile_user_id: channel_socket.assigns[:mobile_user_id],
mobile_authenticated: true,
# Pass mobile params for hooks to access
mobile_params: channel_socket.assigns[:mobile_params] || %{},
# Required LiveView internals
live_action: nil,
flash: %{},
__changed__: %{}
},
private: %{
connect_params: params,
connect_info: %{
transport: :mobile,
user_agent: get_in(params, ["user_agent"]) || "React Native"
}
}
}
end
defp run_on_mount_hooks_sequence(hooks, socket) do
# Execute each hook in sequence (exactly like LiveView does)
Enum.reduce_while(hooks, {:cont, socket}, fn hook, {:cont, acc_socket} ->
case execute_single_on_mount_hook(hook, acc_socket) do
{:cont, updated_socket} ->
{:cont, {:cont, updated_socket}}
{:halt, updated_socket} ->
{:halt, {:halt, updated_socket}}
# Handle error case
{:error, _reason} ->
{:halt, {:halt, acc_socket}}
end
end)
end
defp execute_single_on_mount_hook(hook, socket) do
try do
case hook do
%{function: fun} when is_function(fun) ->
# Phoenix LiveView on_mount hook format with function reference (action, params, session, socket)
# Build session with JWT token for mobile authentication
session = build_session_for_on_mount(socket)
# Set mobile transport context for pipeline-aware auth helpers
Process.put(:transport_context, :mobile)
fun.(:default, %{}, session, socket)
{module, function} when is_atom(module) and is_atom(function) ->
# Call the on_mount function with standard LiveView signature (action, params, session, socket)
session = build_session_for_on_mount(socket)
# Set mobile transport context for pipeline-aware auth helpers
Process.put(:transport_context, :mobile)
apply(module, function, [:default, %{}, session, socket])
module when is_atom(module) ->
# Default to :on_mount function if only module provided (action, params, session, socket)
session = build_session_for_on_mount(socket)
# Set mobile transport context for pipeline-aware auth helpers
Process.put(:transport_context, :mobile)
apply(module, :on_mount, [:default, %{}, session, socket])
_ ->
Logger.warning("Invalid on_mount hook format: #{inspect(hook)}")
{:error, :invalid_hook_format}
end
rescue
error ->
Logger.error("on_mount hook execution failed: #{inspect(error)}")
{:error, :hook_execution_failed}
end
end
defp merge_auth_results_to_channel_socket(channel_socket, live_socket) do
# Merge authentication results from LiveView socket back to channel socket
# This preserves any assigns set by on_mount hooks
new_assigns = Map.merge(channel_socket.assigns, %{
current_user: live_socket.assigns[:current_user],
# Preserve any additional assigns set by hooks
authenticated_user: live_socket.assigns[:current_user] || channel_socket.assigns[:authenticated_user]
})
%{channel_socket | assigns: new_assigns}
end
@doc """
Enhanced authentication that creates a mobile session similar to web sessions.
This function allows developers to provide custom authentication callbacks
and creates a session structure that works with the unified auth helpers.
"""
def authenticate_mobile_user_with_session(socket, params, auth_callback)
when is_function(auth_callback, 2) do
user_id = params["user_id"]
token = params["token"]
# Security validations first
with :ok <- validate_transport_consistency(socket),
:ok <- validate_client_signature(params),
:ok <- validate_required_params(user_id, token),
{:ok, user_data, custom_data} <- perform_auth_callback(auth_callback, user_id, token) do
session = create_mobile_session(user_data, token, user_id, custom_data, socket.assigns)
updated_socket = update_socket_for_auth(socket, user_data, user_id)
{:ok, updated_socket, session}
else
{:error, reason} ->
Logger.warning("Mobile auth failed for user_id=#{user_id}, reason=#{inspect(reason)}")
{:error, reason}
end
end
@doc """
Creates a mobile session structure that's compatible with web sessions.
The session includes:
- current_user: User data for the auth helpers
- mobile_authenticated: Flag for transport detection
- user_token: Token for compatibility
- integrity and metadata for security
"""
def create_mobile_session(user_data, token, user_id, custom_session_data) do
create_mobile_session(user_data, token, user_id, custom_session_data, %{})
end
def create_mobile_session(user_data, token, user_id, custom_session_data, socket_assigns) do
current_time = :os.system_time(:second)
nonce = generate_session_nonce()
base_session = %{
"mobile_authenticated" => true,
"current_user" => user_data,
"user_token" => token,
"mobile_user_id" => user_id,
"__created_at__" => current_time,
"__last_activity__" => current_time,
"__nonce__" => nonce
}
# Merge custom session data
session_with_custom = Map.merge(base_session, custom_session_data)
# Add device metadata and client fingerprint
session_with_metadata = add_device_metadata(session_with_custom, socket_assigns)
|> add_client_fingerprint(socket_assigns)
# Add integrity hash for tamper detection (excluding the hash itself)
integrity_hash = calculate_session_integrity(session_with_metadata)
Map.put(session_with_metadata, "__integrity_hash__", integrity_hash)
end
@doc """
Calculates session integrity hash for tamper detection.
"""
def calculate_session_integrity(session_data) do
# Create deterministic hash of session contents
session_binary = :erlang.term_to_binary(session_data, [:deterministic])
:crypto.hash(:sha256, session_binary) |> Base.encode64()
end
# Private helper functions for session infrastructure
defp call_auth_callback_safely(auth_callback, user_id, token) do
try do
auth_callback.(user_id, token)
rescue
error ->
Logger.error("Auth callback error: #{inspect(error)}")
{:error, :auth_callback_error}
end
end
defp update_socket_for_auth(socket, user_data, user_id) do
# Handle both Phoenix.LiveView.Socket and Phoenix.Socket
case socket do
%Phoenix.LiveView.Socket{} ->
# For LiveView sockets, we need to use Phoenix.LiveView.assign/3
%{socket | assigns:
socket.assigns
|> Map.put(:authenticated_user, user_data)
|> Map.put(:mobile_user_id, user_id)
|> Map.put(:mobile_authenticated, true)}
_ ->
# For Phoenix.Socket, use the standard assign function
socket
|> assign(:authenticated_user, user_data)
|> assign(:mobile_user_id, user_id)
|> assign(:mobile_authenticated, true)
end
end
# Security validation helpers
defp validate_transport_consistency(socket) do
mobile_authenticated = socket.assigns[:mobile_authenticated]
# Strict transport validation - secure by default
case mobile_authenticated do
false ->
# Web socket explicitly marked as non-mobile trying to authenticate as mobile
{:error, :transport_mismatch}
true ->
# Validate mobile indicators are consistent
if is_legitimate_mobile_transport(socket.assigns) do
:ok
else
{:error, :invalid_mobile_transport}
end
_ ->
# Nil/undefined mobile_authenticated is suspicious for mobile auth
{:error, :ambiguous_transport}
end
end
defp validate_client_signature(params) do
# Secure by default - require client signature for production mobile auth
# Allow opt-out only for development/testing
development_mode = System.get_env("MIX_ENV") == "dev" or
System.get_env("MIX_ENV") == "test" or
params["allow_insecure_dev"] == true
case {development_mode, params} do
{true, _} ->
# Development mode - allow without signature
:ok
{false, %{"client_signature" => signature}} when is_binary(signature) ->
# Production mode - validate signature
validate_client_signature_authenticity(signature, params)
{false, _} ->
# Production mode - require signature
{:error, :missing_client_signature}
end
end
defp is_legitimate_mobile_transport(assigns) do
user_agent = assigns[:user_agent] || ""
device_id = assigns[:device_id]
# Comprehensive mobile validation
cond do
# Must have legitimate mobile user agent
not has_mobile_user_agent_indicators(user_agent) -> false
# Must not have web browser indicators
has_web_browser_indicators(user_agent) -> false
# Should have device ID for mobile clients
is_nil(device_id) or device_id == "" -> false
true -> true
end
end
defp has_mobile_user_agent_indicators(user_agent) do
String.contains?(user_agent, "React-Native") or
String.contains?(user_agent, "Mobile-App") or
String.contains?(user_agent, "Expo") or
String.contains?(user_agent, "Flutter") or
String.contains?(user_agent, "Capacitor")
end
defp has_web_browser_indicators(user_agent) do
String.contains?(user_agent, "Mozilla") or
String.contains?(user_agent, "Chrome") or
String.contains?(user_agent, "Safari") or
String.contains?(user_agent, "Firefox") or
String.contains?(user_agent, "Edge") or
String.contains?(user_agent, "Gecko")
end
defp validate_client_signature_authenticity(signature, _params) do
# In production, this should validate against known app signatures
# For now, just validate basic structure and entropy
cond do
byte_size(signature) < 32 ->
{:error, :invalid_signature_length}
not String.match?(signature, ~r/^[A-Za-z0-9+\/=]+$/) ->
{:error, :invalid_signature_format}
# TODO: Add actual signature verification against app certificate
true ->
:ok
end
end
defp validate_required_params(user_id, token) do
cond do
is_nil(user_id) or user_id == "" -> {:error, :missing_auth_params}
is_nil(token) or token == "" -> {:error, :missing_auth_params}
true -> :ok
end
end
defp perform_auth_callback(auth_callback, user_id, token) do
case call_auth_callback_safely(auth_callback, user_id, token) do
{:ok, user_data} ->
{:ok, user_data, %{}}
{:ok, user_data, custom_session_data} ->
{:ok, user_data, custom_session_data}
{:error, reason} ->
{:error, reason}
end
end
defp add_device_metadata(session, socket_assigns) do
mobile_params = socket_assigns[:mobile_params] || %{}
# Add device metadata if available
device_fields = ["device_id", "app_version", "platform", "os_version"]
Enum.reduce(device_fields, session, fn field, acc_session ->
case mobile_params[field] do
nil -> acc_session
value -> Map.put(acc_session, field, value)
end
end)
end
defp add_client_fingerprint(session, socket_assigns) do
# Create client fingerprint for session binding
fingerprint = %{
"user_agent" => socket_assigns[:user_agent] || "",
"device_id" => socket_assigns[:device_id] || "",
"app_version" => socket_assigns[:app_version] || ""
}
Map.put(session, "__client_fingerprint__", fingerprint)
end
defp generate_session_nonce do
# Generate cryptographically secure random nonce
:crypto.strong_rand_bytes(16) |> Base.encode64()
end
def resolve_live_view_module(live_path, params) do
# Allow tests to specify the LiveView module directly
case params["live_view_module"] do
module when is_atom(module) and not is_nil(module) ->
{:ok, module, %{}}
_ ->
# Use Phoenix Router to resolve the LiveView module for the given path
# This is the proper way - let the router handle path resolution
case resolve_via_router(live_path, params) do
{:ok, live_view_module, session_info, _route_info} ->
{:ok, live_view_module, session_info}
error ->
error
end
end
end
def resolve_live_view_module_with_route(live_path, params) do
# NEW: Returns route_info for path parameter extraction
case params["live_view_module"] do
module when is_atom(module) and not is_nil(module) ->
# Test mode - create mock route_info
mock_route_info = %{route: live_path} # No path parameters in test mode
{:ok, module, %{}, mock_route_info}
_ ->
# Use Phoenix Router to resolve with full route_info
resolve_via_router(live_path, params)
end
end
defp resolve_via_router(live_path, params) do
try do
# FIXED: Get router from socket.endpoint or use hardcoded fallback
socket = params["socket"]
router = if socket && socket.endpoint do
# Try to get router from endpoint, fallback to convention
try do
socket.endpoint.__router__()
rescue
_ ->
# Use naming convention: MyAppWeb.Endpoint -> MyAppWeb.Router
endpoint_module = socket.endpoint
parts = Module.split(endpoint_module)
base_module = parts |> Enum.take(length(parts) - 1) |> Module.concat()
Module.concat(base_module, "Router")
end
else
# Fallback to hardcoded for demo
ServerWeb.Router
end
Logger.debug("Resolving route: #{live_path} with router: #{inspect(router)}")
case Phoenix.Router.route_info(router, "GET", live_path, "") do
# LiveView routes: module is in mfa tuple, session info in phoenix_live_view
%{
plug: Phoenix.LiveView.Plug,
mfa: {live_view_module, :__live__, 0},
phoenix_live_view: {_, _, _, live_session_metadata}
} = route_info when is_atom(live_view_module) ->
# Extract live_session info from the phoenix_live_view metadata
session_info = extract_live_session_info_from_metadata(live_session_metadata)
{:ok, live_view_module, session_info, route_info}
%{} = other_route_info ->
IO.puts("Route exists but unexpected structure: #{inspect(other_route_info)}")
{:error, "route exists but not a LiveView for path: #{live_path}"}
:error ->
{:error, "no route found for path: #{live_path}"}
end
rescue
e ->
Logger.error("Router resolution failed: #{inspect(e)}")
{:error, "router resolution failed: #{inspect(e)}"}
end
end
defp extract_live_session_info_from_metadata(live_session_metadata) do
# Extract live_session info directly from the phoenix_live_view metadata
%{
live_session_name: Map.get(live_session_metadata, :name),
on_mount: get_in(live_session_metadata, [:extra, :on_mount]) || []
}
end
# ============================================================================
# OLD PROCESS SPAWNING LOGIC REMOVED - Now using Channel-as-Process model
# ============================================================================
# Expose for testing
if Mix.env() == :test do
def check_live_session_boundary(from_path, to_path, router) do
check_live_session_boundary_impl(from_path, to_path, router)
end
end
defp check_live_session_boundary_impl(from_path, to_path, router) do
from_session = get_live_session_name(from_path, router)
to_session = get_live_session_name(to_path, router)
cond do
from_session == nil or to_session == nil -> :error
from_session == to_session -> :same_session
true -> :different_session
end
end
defp get_live_session_name(path, router) do
case Phoenix.Router.route_info(router, "GET", path, "mobile") do
:error -> nil
%{phoenix_live_view: {_, _, _, live_session_metadata}} ->
Map.get(live_session_metadata, :name)
_ -> nil
end
end
defp handle_mobile_navigation(new_path, params, socket) do
Logger.info("Mobile navigation within same session: #{new_path}")
try do
# Step 1: Resolve new LiveView via router (same as join/3)
case resolve_live_view_module(new_path, Map.put(params, "socket", socket)) do
{:ok, new_live_view_module, session_info} ->
# Step 2: Execute auth hooks for new route (same security as join/3)
case execute_on_mount_hooks(session_info, socket, params) do
{:ok, auth_validated_socket} ->
# Step 3: Mount new LiveView directly (same as join/3)
case mount_live_view_directly(new_live_view_module, params, auth_validated_socket) do
{:ok, new_assigns, new_commands} ->
# Step 4: Update channel state with new LiveView
updated_socket = auth_validated_socket
|> assign(:live_view_module, new_live_view_module)
|> assign(:live_path, new_path)
|> assign(:live_assigns, new_assigns)
# Step 5: Send navigation complete to mobile client
push(socket, "navigation_complete", %{
path: new_path,
assigns: new_assigns,
commands: new_commands
})
Logger.info("Mobile navigation completed successfully: #{new_path}")
{:noreply, updated_socket}
{:error, mount_reason} ->
Logger.error("Navigation mount failed: #{inspect(mount_reason)}")
push(socket, "navigation_failed", %{
path: new_path,
reason: "mount_failed",
details: inspect(mount_reason)
})
{:noreply, socket}
end
{:error, auth_reason} ->
Logger.error("Navigation auth failed: #{inspect(auth_reason)}")
push(socket, "navigation_failed", %{
path: new_path,
reason: "authentication_failed",
details: inspect(auth_reason)
})
{:noreply, socket}
end
{:error, resolve_reason} ->
Logger.error("Navigation resolve failed: #{inspect(resolve_reason)}")
push(socket, "navigation_failed", %{
path: new_path,
reason: "route_resolution_failed",
details: inspect(resolve_reason)
})
{:noreply, socket}
end
rescue
error ->
Logger.error("Navigation exception: #{inspect(error)}")
push(socket, "navigation_failed", %{
path: new_path,
reason: "navigation_exception",
details: inspect(error)
})
{:noreply, socket}
end
end
# OLD try_forward_event REMOVED - Phase 2 will implement direct LiveView.handle_event/3 calls
# ============================================================================
# Phase 1: Core LiveView Integration (Channel-as-Process Model)
# ============================================================================
# Mount LiveView directly and store state in channel (no separate process).
# This is the new Channel-as-Process approach where the channel holds LiveView state.
defp mount_live_view_directly(live_view_module, params, socket) do
try do
# Build LiveView-compatible socket from channel socket
live_socket = build_live_socket_for_mount(socket, params)
# Call LiveView's mount function DIRECTLY (no process spawning!)
case live_view_module.mount(params, %{}, live_socket) do
{:ok, mounted_socket} ->
# Extract assigns and commands from mounted LiveView socket
initial_assigns = convert_to_mobile_assigns(mounted_socket.assigns)
initial_commands = extract_mobile_commands(mounted_socket)
{:ok, initial_assigns, initial_commands}
{:ok, mounted_socket, opts} ->
# Phase 3: Handle mount options (redirects, navigation)
handle_mount_options(mounted_socket, opts, socket)
{:error, reason} ->
{:error, "mount_failed: #{inspect(reason)}"}
end
rescue
error ->
Logger.error("LiveView mount error: #{inspect(error)}")
{:error, "mount_error: #{inspect(error)}"}
end
end
defp build_live_socket_for_mount(channel_socket, params) do
# Build LiveView-compatible socket from channel socket
endpoint_module = channel_socket.endpoint |> Module.split() |> Enum.take(2) |> Module.concat()
router = Module.concat(endpoint_module, "Router")
%Phoenix.LiveView.Socket{
endpoint: channel_socket.endpoint,
router: router,
# Set transport_pid so connected?(socket) returns true for PubSub subscriptions
transport_pid: self(),
assigns: %{
# Copy authentication context from channel
current_user: channel_socket.assigns[:authenticated_user],
mobile_user_id: channel_socket.assigns[:mobile_user_id],
mobile_authenticated: true,
mobile_params: channel_socket.assigns[:mobile_params] || %{},
# Required LiveView internals
live_action: nil,
flash: %{},
__changed__: %{}
},
private: %{
connect_params: params,
connect_info: %{
transport: :mobile,
user_agent: get_in(params, ["user_agent"]) || "React Native"
}
}
}
end
defp convert_to_mobile_assigns(live_assigns) do
# Convert LiveView assigns to mobile-friendly format
# Remove internal LiveView stuff, keep app data
live_assigns
|> Map.drop([:flash, :live_action, :myself, :__changed__])
|> Map.put(:transport, :mobile)
end
defp extract_mobile_commands(live_socket) do
# Extract React Native commands from LiveView socket
commands = live_socket.assigns[:__rn_commands__] || []
# Convert to JSON-safe format
converted = Enum.map(commands, fn {cmd, payload} -> %{"type" => cmd, "payload" => payload} end)
converted
end
# Clear RN commands from socket to prevent JSON encoding issues
defp clear_rn_commands(live_socket) do
%{live_socket | assigns: Map.delete(live_socket.assigns, :__rn_commands__)}
end
# Build session data for on_mount hooks - includes JWT token for mobile authentication
defp build_session_for_on_mount(socket) do
# Extract JWT token from mobile_params (set during socket connection)
jwt_token = get_in(socket.assigns, [:mobile_params, "token"])
if jwt_token do
%{"jwt_token" => jwt_token}
else
%{}
end
end
# Handle LiveView mount options (redirects, etc.) for mobile clients
defp handle_mount_options(mounted_socket, opts, channel_socket) do
# Process mount options like {:redirect, to: "/path"} or {:push_navigate, to: "/path"}
case opts do
{:redirect, redirect_opts} ->
redirect_to = Keyword.get(redirect_opts, :to)
Logger.info("LiveView mount redirect requested: #{redirect_to}")
# For mobile, we treat redirects as navigation requests
push(channel_socket, "mount_redirect", %{
redirect_to: redirect_to,
reason: "liveview_mount_redirect"
})
# Return empty state since we're redirecting
{:ok, %{}, []}
{:push_navigate, navigate_opts} ->
navigate_to = Keyword.get(navigate_opts, :to)
Logger.info("LiveView mount navigation requested: #{navigate_to}")
# For mobile, we treat push_navigate as navigation requests
push(channel_socket, "mount_navigate", %{
navigate_to: navigate_to,
reason: "liveview_mount_navigate"
})
# Return empty state since we're navigating
{:ok, %{}, []}
_ ->
# No special mount options, proceed normally
initial_assigns = convert_to_mobile_assigns(mounted_socket.assigns)
initial_commands = extract_mobile_commands(mounted_socket)
{:ok, initial_assigns, initial_commands}
end
end
# ============================================================================
# Phase 2: Event Handling (Direct LiveView Function Calls)
# ============================================================================
# Rebuild LiveView socket from current channel state for event handling.
# This allows us to call LiveView.handle_event/3 directly with proper context.
defp build_live_socket_with_assigns(channel_socket, current_assigns) do
# Get router from endpoint
endpoint_module = channel_socket.endpoint |> Module.split() |> Enum.take(2) |> Module.concat()
router = Module.concat(endpoint_module, "Router")
%Phoenix.LiveView.Socket{
endpoint: channel_socket.endpoint,
router: router,
assigns: Map.merge(current_assigns, %{
# Preserve authentication context from channel
current_user: channel_socket.assigns[:authenticated_user],
mobile_user_id: channel_socket.assigns[:mobile_user_id],
mobile_authenticated: true,
mobile_params: channel_socket.assigns[:mobile_params] || %{},
# Required LiveView internals
live_action: nil,
flash: %{},
__changed__: %{}
}),
private: %{
connect_info: %{
transport: :mobile,
user_agent: "React Native"
}
}
}
end
# Handle events by calling LiveView.handle_event/3 directly and updating channel state
def handle_live_view_event(event_name, payload, socket) do
live_view_module = socket.assigns[:live_view_module]
current_assigns = socket.assigns[:live_assigns] || %{}
if live_view_module do
# Rebuild LiveView socket with current state
live_socket = build_live_socket_with_assigns(socket, current_assigns)
try do
# Call LiveView's handle_event function DIRECTLY
case live_view_module.handle_event(event_name, payload, live_socket) do
{:noreply, updated_live_socket} ->
# Extract new commands first (before clearing them)
new_commands = extract_mobile_commands(updated_live_socket)
# Clear RN commands to prevent JSON encoding issues
cleared_socket = clear_rn_commands(updated_live_socket)
# Extract new assigns from cleared socket
new_assigns = convert_to_mobile_assigns(cleared_socket.assigns)
# Update channel state with new LiveView state
updated_socket = assign(socket, :live_assigns, new_assigns)
# Send updates to mobile client
push(socket, "assigns_update", %{assigns: new_assigns, commands: new_commands})
{:noreply, updated_socket}
{:reply, reply, updated_live_socket} ->
# Extract new commands first (before clearing them)
new_commands = extract_mobile_commands(updated_live_socket)
# Clear RN commands to prevent JSON encoding issues
cleared_socket = clear_rn_commands(updated_live_socket)
# Extract new assigns from cleared socket
new_assigns = convert_to_mobile_assigns(cleared_socket.assigns)
# Update channel state with new LiveView state
updated_socket = assign(socket, :live_assigns, new_assigns)
# Send updates + reply to mobile client
push(socket, "assigns_update", %{assigns: new_assigns, commands: new_commands})
{:reply, {:ok, %{reply: reply}}, updated_socket}
{:error, reason} ->
Logger.error("LiveView handle_event error: #{inspect(reason)}")
{:reply, {:error, %{reason: "event_error", details: inspect(reason)}}, socket}
end
rescue
error ->
Logger.error("LiveView handle_event exception: #{inspect(error)}")
{:reply, {:error, %{reason: "event_exception", details: inspect(error)}}, socket}
end
else
Logger.error("No LiveView module found for event: #{event_name}")
{:reply, {:error, %{reason: "no_live_view_module"}}, socket}
end
end
# Handle external messages by calling LiveView.handle_info/2 directly and updating channel state
defp handle_live_view_info(msg, socket) do
live_view_module = socket.assigns[:live_view_module]
current_assigns = socket.assigns[:live_assigns] || %{}
if live_view_module do
# Rebuild LiveView socket with current state
live_socket = build_live_socket_with_assigns(socket, current_assigns)
try do
# Call LiveView's handle_info function DIRECTLY
case live_view_module.handle_info(msg, live_socket) do
{:noreply, updated_live_socket} ->
# Extract new commands first (before clearing them)
new_commands = extract_mobile_commands(updated_live_socket)
# Clear RN commands to prevent accumulation
cleared_socket = clear_rn_commands(updated_live_socket)
# Extract new assigns from cleared socket
new_assigns = convert_to_mobile_assigns(cleared_socket.assigns)
# Update channel state with new LiveView state
updated_socket = assign(socket, :live_assigns, new_assigns)
# Send updates to mobile client
push(socket, "assigns_update", %{assigns: new_assigns, commands: new_commands})
{:noreply, updated_socket}
{:error, reason} ->
Logger.error("LiveView handle_info error: #{inspect(reason)}")
{:noreply, socket}
end
rescue
error ->
Logger.error("LiveView handle_info exception: #{inspect(error)}")
{:noreply, socket}
end
else
Logger.debug("No LiveView module found for message: #{inspect(msg)}")
{:noreply, socket}
end
end
# =============================================================================
# URL PARAMETER PARSING - Phoenix Router Style (TDD Implementation)
# =============================================================================
@doc """
Parses URL parameters from a mobile path, splitting path and query string.
Examples:
parse_url_parameters("/mobile/posts?category=tech&page=2")
# => {"/mobile/posts", %{"category" => "tech", "page" => "2"}}
parse_url_parameters("/mobile/counter")
# => {"/mobile/counter", %{}}
"""
def parse_url_parameters(path) do
case String.split(path, "?", parts: 2) do
[clean_path] ->
# No query string
{clean_path, %{}}
[clean_path, query_string] ->
# Parse query string like Phoenix Router does
params = query_string
|> String.split("&")
|> Enum.reduce(%{}, fn param, acc ->
case String.split(param, "=", parts: 2) do
[key] -> Map.put(acc, URI.decode_www_form(key), "")
[key, value] -> Map.put(acc, URI.decode_www_form(key), URI.decode_www_form(value))
end
end)
{clean_path, params}
end
end
@doc """
Extracts path parameters from route patterns, exactly like Phoenix Router.
Examples:
extract_path_parameters("/mobile/users/123", "/mobile/users/:id")
# => %{"id" => "123"}
extract_path_parameters("/mobile/api/v2/pages/100", "/mobile/api/v:version/pages/:id")
# => %{"version" => "2", "id" => "100"}
"""
def extract_path_parameters(actual_path, route_pattern) do
actual_segments = String.split(actual_path, "/", trim: true)
pattern_segments = String.split(route_pattern, "/", trim: true)
# Handle wildcards specially - they can match multiple segments
wildcard_index = Enum.find_index(pattern_segments, &String.starts_with?(&1, "*"))
if wildcard_index do
# Wildcard pattern handling
extract_wildcard_parameters(actual_segments, pattern_segments, wildcard_index)
else
# Regular pattern matching - must have same length
if length(actual_segments) != length(pattern_segments) do
%{}
else
actual_segments
|> Enum.zip(pattern_segments)
|> Enum.reduce(%{}, fn {actual, pattern}, acc ->
cond do
String.starts_with?(pattern, ":") ->
# Full parameter like :id
param_name = String.slice(pattern, 1..-1//1)
Map.put(acc, param_name, actual)
String.contains?(pattern, ":") ->
# Partial parameter like v:version
extract_partial_parameter(actual, pattern, acc)
actual == pattern ->
# Exact match, no parameter
acc
true ->
# No match, return empty
%{}
end
end)
end
end
end
defp extract_wildcard_parameters(actual_segments, pattern_segments, wildcard_index) do
# Extract parameters before wildcard
before_wildcard = Enum.take(pattern_segments, wildcard_index)
before_actual = Enum.take(actual_segments, wildcard_index)
# Extract parameters after wildcard
after_wildcard = Enum.drop(pattern_segments, wildcard_index + 1)
after_count = length(after_wildcard)
after_actual = Enum.take(actual_segments, -after_count)
# Extract wildcard value (everything in between)
wildcard_pattern = Enum.at(pattern_segments, wildcard_index)
wildcard_param = String.slice(wildcard_pattern, 1..-1//1)
wildcard_start = wildcard_index
wildcard_end = length(actual_segments) - after_count
wildcard_segments = Enum.slice(actual_segments, wildcard_start, wildcard_end - wildcard_start)
wildcard_value = Enum.join(wildcard_segments, "/")
# Combine all parameters
before_params = if length(before_wildcard) == length(before_actual) do
Enum.zip(before_actual, before_wildcard)
|> Enum.reduce(%{}, fn {actual, pattern}, acc ->
if String.starts_with?(pattern, ":") do
param_name = String.slice(pattern, 1..-1//1)
Map.put(acc, param_name, actual)
else
acc
end
end)
else
%{}
end
after_params = if length(after_wildcard) == length(after_actual) do
Enum.zip(after_actual, after_wildcard)
|> Enum.reduce(%{}, fn {actual, pattern}, acc ->
if String.starts_with?(pattern, ":") do
param_name = String.slice(pattern, 1..-1//1)
Map.put(acc, param_name, actual)
else
acc
end
end)
else
%{}
end
before_params
|> Map.merge(after_params)
|> Map.put(wildcard_param, wildcard_value)
end
defp extract_partial_parameter(actual_segment, pattern_segment, acc) do
# Handle patterns like "v:version" where actual is "v2"
case String.split(pattern_segment, ":") do
[prefix, param_name] ->
if String.starts_with?(actual_segment, prefix) do
param_value = String.slice(actual_segment, String.length(prefix)..-1)
Map.put(acc, param_name, param_value)
else
%{}
end
_ ->
acc
end
end
@doc """
Extracts path parameters from Phoenix Router route_info and actual path.
"""
def extract_path_params_from_route_info(route_info, actual_path) do
case route_info do
%{route: route_pattern} ->
extract_path_parameters(actual_path, route_pattern)
_ ->
%{}
end
end
@doc """
Combines mount params, path params, and query params with proper precedence.
Path params override mount params (Phoenix Router behavior).
"""
def combine_all_params(mount_params, path_params, query_params) do
mount_params
|> Map.merge(path_params) # Path params override mount params
|> Map.merge(query_params) # Query params are additive
end
@doc """
Mounts LiveView with handle_params/3 support (like web LiveView does).
Calls both mount/3 AND handle_params/3 in sequence:
1. mount/3 with mount params (device_id, user_id, etc.)
2. handle_params/3 with URL params (query + path parameters)
"""
def mount_with_params(live_view_module, mount_params, url_params, uri, socket) do
try do
# Step 1: Build LiveView-compatible socket
live_socket = build_live_socket_for_mount(socket, mount_params)
# Step 2: Call LiveView's mount/3 function (same as before)
case live_view_module.mount(mount_params, %{}, live_socket) do
{:ok, mounted_socket} ->
# Step 3: Check if LiveView implements handle_params/3
if function_exported?(live_view_module, :handle_params, 3) do
# Step 4: Call handle_params/3 with URL parameters (NEW!)
case live_view_module.handle_params(url_params, uri, mounted_socket) do
{:noreply, params_socket} ->
# Extract final assigns and commands after both mount + handle_params
final_assigns = convert_to_mobile_assigns(params_socket.assigns)
final_commands = extract_mobile_commands(params_socket)
{:ok, final_assigns, final_commands}
{:error, reason} ->
{:error, "handle_params_failed: #{inspect(reason)}"}
end
else
# LiveView doesn't implement handle_params/3 - proceed with mount result
initial_assigns = convert_to_mobile_assigns(mounted_socket.assigns)
initial_commands = extract_mobile_commands(mounted_socket)
{:ok, initial_assigns, initial_commands}
end
{:ok, mounted_socket, opts} ->
# Handle mount options (redirects, etc.) - same as before
handle_mount_options(mounted_socket, opts, socket)
{:error, reason} ->
{:error, "mount_failed: #{inspect(reason)}"}
end
rescue
error ->
Logger.error("LiveView mount_with_params error: #{inspect(error)}")
{:error, "mount_with_params_error: #{inspect(error)}"}
end
end
@doc """
Alias for extract_path_parameters/2 - for test compatibility.
"""
def match_route_pattern(actual_path, route_pattern) do
extract_path_parameters(actual_path, route_pattern)
end
@doc """
Alias for handle_params_navigation/3 - for test compatibility.
"""
def handle_navigation_with_params(new_path, _params, socket) do
# Parse URL parameters from the new path
{_clean_path, query_params} = parse_url_parameters(new_path)
# For now, assume no path parameters (would need route_info to extract them)
# This is a simplified version for testing
handle_params_navigation(query_params, new_path, socket)
end
@doc """
Alias for mount_with_params/5 but with different signature for test compatibility.
"""
def mount_live_view_with_params(live_view_module, mount_params, path, socket) do
# Parse URL parameters from path
{_clean_path, query_params} = parse_url_parameters(path)
# Combine parameters (no path params in this simplified version)
url_params = Map.merge(mount_params, query_params)
# Call the real function
mount_with_params(live_view_module, mount_params, url_params, path, socket)
end
@doc """
Handles navigation with handle_params/3 (for parameter-only changes).
Used when navigating within the same LiveView with new parameters.
"""
def handle_params_navigation(url_params, uri, socket) do
live_view_module = socket.assigns[:live_view_module]
current_assigns = socket.assigns[:live_assigns] || %{}
if live_view_module && function_exported?(live_view_module, :handle_params, 3) do
try do
# Rebuild LiveView socket with current state
live_socket = build_live_socket_with_assigns(socket, current_assigns)
# Call handle_params/3 with new URL parameters
case live_view_module.handle_params(url_params, uri, live_socket) do
{:noreply, updated_socket} ->
# Extract new commands first (before clearing them)
new_commands = extract_mobile_commands(updated_socket)
# Clear RN commands to prevent accumulation
cleared_socket = clear_rn_commands(updated_socket)
# Update channel state with new assigns
new_assigns = convert_to_mobile_assigns(cleared_socket.assigns)
updated_channel_socket = socket
|> assign(:live_assigns, new_assigns)
# Push updates to mobile client
push(socket, "assigns_update", %{assigns: new_assigns, commands: new_commands})
{:noreply, updated_channel_socket}
{:error, reason} ->
Logger.error("handle_params navigation failed: #{inspect(reason)}")
{:noreply, socket}
end
rescue
error ->
Logger.error("handle_params navigation exception: #{inspect(error)}")
{:noreply, socket}
end
else
# LiveView doesn't implement handle_params/3 or no LiveView loaded
{:noreply, socket}
end
end
end