Portfolio

Cheatsheet: React Native (Expo) + Amplify Gen 2 for Protected S3 Media

18 min read
React Native
Expo
AWS Amplify
S3
Authentication
Mobile

🚀 Cheatsheet: React Native (Expo) + Amplify Gen 2 for Protected S3 Media 🚀

Goal: Securely display images, videos, or audio from S3 within a React Native (Expo) app. Only logged-in users can access content under protected/, and only the file owner can access content under private/{user_identity_id}/.
Assumptions: You have Node.js, npm/yarn, Expo CLI (npm install -g expo-cli), and the Amplify CLI installed and configured (amplify configure). You have a simulator (iOS/Android) or a physical device with the Expo Go app installed.

Steps:

Step 0: Project Initialization

Create Expo App:
bash
bash

npx create-expo-app my-amplify-rn-app
cd my-amplify-rn-app

Amplify Backend: (Run inside your project directory)
bash
bash

# Choose one: Sandbox (local dev) OR Deployed Env

npx amplify sandbox          # For rapid local development (auto-deploys on save)
# --- OR ---

npx amplify init            # To create a cloud environment (requires `amplify push`)

Step 1: Define Backend (Auth & Storage)

This step is IDENTICAL to the Next.js version. The backend setup (amplify/auth/resource.ts, amplify/storage/resource.ts) is cloud-side and platform-agnostic.
Ensure your amplify/storage/resource.ts includes:
protected/* rule with allow.authenticated.to(['read']).
private/{entity_id}/* rule with allow.entity('identity').to(['read', 'write', 'delete']).
Linking to auth.resources.userPool and auth.resources.identityPool.
Deploy Backend: Use amplify sandbox (auto) or npx amplify push.

Step 2: Install Frontend Libraries

bash
bash

# Core Amplify & UI for React Native

npm install aws-amplify @aws-amplify/ui-react-native

# Media Playback (Expo AV)

npx expo install expo-av

# Navigation (React Navigation)

npm install @react-navigation/native @react-navigation/native-stack
npx expo install react-native-screens react-native-safe-area-context

Step 3: Configure Amplify in React Native App (❗ Different from Web!)

Edit your app's entry point (usually App.tsx):
typescript
typescript

// App.tsx (or your main entry point)
import React from 'react';
import { Amplify } from 'aws-amplify';
// Adjust path if amplify_outputs.json is elsewhere
import outputs from './amplify_outputs.json';
import { Authenticator } from '@aws-amplify/ui-react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';

// Import your screen components (we'll create these later)
import HomeScreen from './screens/HomeScreen';
import ProtectedMediaScreen from './screens/ProtectedMediaScreen';
// import MyMediaScreen from './screens/MyMediaScreen'; // For private files later
// import UploadScreen from './screens/UploadScreen'; // For uploads later

// Configure Amplify ONCE at the root
Amplify.configure(outputs);

// Define the navigation stack
const Stack = createNativeStackNavigator();

// Component shown AFTER authentication
function AppContent() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        {/* Define screens accessible after login */}
        <Stack.Screen
          name="Home"
          component={HomeScreen}
          options={{ title: 'Dashboard' }}
        />
        <Stack.Screen
          name="ProtectedMedia"
          component={ProtectedMediaScreen}
          options={{ title: 'Protected Media' }}
        />
        {/* Add other screens like MyMediaScreen, UploadScreen here */}
      </Stack.Navigator>
    </NavigationContainer>
  );
}

// Main App Component using Authenticator
export default function App() {
  return (
    // Authenticator handles login UI automatically
    <Authenticator.Provider>
      <Authenticator>
        {/* AppContent is rendered only after successful login */}
        <AppContent />
      </Authenticator>
    </Authenticator.Provider>
  );
}

amplify_outputs.json: Ensure it exists (usually root) and has storage details after backend deployment. Run amplify pull if needed.

Step 4: Create Basic Screens & Navigation

Create screens/HomeScreen.tsx: (Example dashboard after login)
typescript
typescript

// screens/HomeScreen.tsx
import React from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import { useAuthenticator } from '@aws-amplify/ui-react-native'; // Hook to get user/signOut

// Type navigation prop for type safety (adjust 'ProtectedMedia' based on your screen names)
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
type RootStackParamList = { Home: undefined; ProtectedMedia: undefined; /* Add other screens */ };
type Props = NativeStackScreenProps<RootStackParamList, 'Home'>;

export default function HomeScreen({ navigation }: Props) {
  // Get user details and signOut function from Authenticator context
  const { user, signOut } = useAuthenticator((context) => [context.user]);

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Welcome, {user?.username || 'User'}!</Text>
      <View style={styles.buttonContainer}>
        <Button
          title="View Protected Media"
          onPress={() => navigation.navigate('ProtectedMedia')}
        />
        {/* Add buttons for other screens later */}
        {/* <Button title="View My Media" onPress={() => navigation.navigate('MyMedia')} /> */}
        {/* <Button title="Upload File" onPress={() => navigation.navigate('Upload')} /> */}
      </View>
      <Button title="Sign Out" onPress={signOut} color="red" />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 20 },
  title: { fontSize: 20, fontWeight: 'bold', marginBottom: 20 },
  buttonContainer: { marginVertical: 20, width: '80%' },
});

(Remember to create the other screen files mentioned in App.tsx later)

Step 5: Create Reusable Media Display Component (React Native Version)

Create components/ProtectedMediaDisplay.tsx:
typescript
typescript

// components/ProtectedMediaDisplay.tsx
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, Image, Button, StyleSheet, ActivityIndicator, Pressable } from 'react-native';
import { getUrl } from 'aws-amplify/storage';
import { ResizeMode, Video } from 'expo-av'; // Import Expo AV components
import { Audio } from 'expo-av';

interface ProtectedMediaDisplayProps {
  path: string;
  alt?: string; // Used as accessibility label
  style?: object; // Pass custom styles
}

function getMediaType(filePath: string): 'image' | 'video' | 'audio' | 'unknown' {
    // (Keep the same getMediaType function from the web version)
    const extension = filePath.split('.').pop()?.toLowerCase();
    if (!extension) return 'unknown';
    if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension)) return 'image'; // Removed svg for simplicity
    if (['mp4', 'webm', 'mov'].includes(extension)) return 'video'; // Simplified list
    if (['mp3', 'wav', 'm4a', 'aac'].includes(extension)) return 'audio'; // Simplified list
    return 'unknown';
}

export default function ProtectedMediaDisplay({ path, alt = "Protected Media", style }: ProtectedMediaDisplayProps) {
  const [mediaUrl, setMediaUrl] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [mediaType, setMediaType] = useState<'image' | 'video' | 'audio' | 'unknown'>('unknown');

  // Specific state/refs for media players
  const videoRef = useRef<Video>(null);
  const [sound, setSound] = useState<Audio.Sound | null>(null);
  const [isPlayingAudio, setIsPlayingAudio] = useState(false);

  useEffect(() => {
    // --- Cleanup audio on unmount or path change ---
    return () => {
      sound?.unloadAsync();
    };
  }, [sound]);

  useEffect(() => {
    setMediaUrl(null); setLoading(true); setError(null); setMediaType('unknown'); setIsPlayingAudio(false);
    // Ensure previous sound is unloaded if path changes
    sound?.unloadAsync().then(() => setSound(null)).catch(e => console.log("Error unloading previous sound:", e));


    if (!path) {
      setError('No media path provided.'); setLoading(false); return;
    }

    const fetchMediaUrl = async () => {
      try {
        const type = getMediaType(path);
        setMediaType(type);

        const result = await getUrl({ path, options: { validateObjectExistence: true } });
        setMediaUrl(result.url.toString());
        setError(null);

         // Pre-configure Audio Session (Good Practice for iOS/Android)
         if (type === 'audio') {
            await Audio.setAudioModeAsync({
                allowsRecordingIOS: false,
                playsInSilentModeIOS: true, // Allow playing even if phone is on silent
                staysActiveInBackground: false, // Adjust if background play needed
                shouldDuckAndroid: true, // Lower other audio on Android
                playThroughEarpieceAndroid: false,
            });
         }

      } catch (err: any) {
        console.error(`Error getting URL for ${path}:`, err);
        setError(err.name === 'NotFound' ? `Media not found: ${path}` : `Failed to load: ${err.message || err.name}`);
        setMediaUrl(null);
      } finally {
        setLoading(false);
      }
    };
    fetchMediaUrl();
  }, [path]); // Re-run if path changes

  // --- Audio Playback Logic ---
  async function playSound() {
    if (!mediaUrl) return;
    console.log('Loading Sound');
    setIsPlayingAudio(true); // Indicate loading/playing attempt
    try {
      // Check if sound is already loaded
       if(sound) {
          console.log('Playing existing sound');
          await sound.replayAsync();
       } else {
          console.log('Loading new sound from:', mediaUrl)
          const { sound: newSound } = await Audio.Sound.createAsync(
             { uri: mediaUrl },
             { shouldPlay: true } // Start playing immediately
           );
           setSound(newSound);
           console.log('Playing Sound');
           newSound.setOnPlaybackStatusUpdate((status) => {
             if (status.isLoaded && status.didJustFinish) {
               // Reset button state when finished
               setIsPlayingAudio(false);
               console.log("Playback finished");
               // Optionally unload or prepare for replay
               // sound?.setPositionAsync(0); // Rewind
             } else if (status.isLoaded && !status.isPlaying && !status.didJustFinish) {
                // Handle pausing if pause functionality is added
                setIsPlayingAudio(false);
             } else if (!status.isLoaded && status.error){
                console.error("Playback Error:", status.error);
                setError(`Playback Error: ${status.error}`)
                setIsPlayingAudio(false);
             }
           });
       }

    } catch (error) {
      console.error('Failed to load or play sound', error);
      setError(`Failed to play audio: ${error}`);
      setIsPlayingAudio(false);
    }
  }

  // --- Rendering Logic ---
  if (loading) return <ActivityIndicator size="large" color="#007AFF" style={[styles.centered, style]} />;
  if (error) return <Text style={[styles.errorText, style]}>{error}</Text>;
  if (!mediaUrl) return null;

  switch (mediaType) {
    case 'image':
      return (
        <Image
          source={{ uri: mediaUrl }}
          style={[styles.image, style]} // Combine default and passed styles
          resizeMode="contain" // Or 'cover', 'stretch'
          accessibilityLabel={alt}
        />
      );
    case 'video':
      return (
        <Video
          ref={videoRef}
          style={[styles.video, style]}
          source={{ uri: mediaUrl }}
          useNativeControls // Use platform-native video controls
          resizeMode={ResizeMode.CONTAIN} // Or COVER
          isLooping={false} // Optional
          // onPlaybackStatusUpdate={status => setStatus(() => status)} // For custom controls
          onError={(e) => { console.error("Video Error:", e); setError(`Video playback error.`);}}
          accessibilityLabel={alt}
        />
      );
    case 'audio':
      return (
        <View style={[styles.audioContainer, style]}>
          <Text style={styles.audioLabel}>{alt}: </Text>
          <Button
            title={isPlayingAudio ? "Playing..." : "Play Audio"}
            onPress={playSound}
            disabled={isPlayingAudio && sound != null} // Disable if currently playing/loading
          />
        </View>
      );
    default:
      return (
        <View style={style}>
          <Text style={styles.errorText}>Unsupported type for: {path}</Text>
          {/* Optionally add a button to open URL in browser */}
        </View>
      );
  }
}

// --- Basic Styles ---
const styles = StyleSheet.create({
  centered: { alignSelf: 'center', marginVertical: 20 },
  errorText: { color: 'red', textAlign: 'center', marginVertical: 10 },
  image: { width: '100%', aspectRatio: 16/9, marginBottom: 10, alignSelf: 'center' }, // Default aspect ratio
  video: { width: '100%', aspectRatio: 16/9, marginBottom: 10, alignSelf: 'center', backgroundColor: 'black'},
  audioContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', padding: 10, borderWidth: 1, borderColor: '#ccc', borderRadius: 5, marginVertical: 10 },
  audioLabel: { marginRight: 10 },
});

Step 6: Use the Display Component on a Screen

Create screens/ProtectedMediaScreen.tsx:
typescript
typescript

// screens/ProtectedMediaScreen.tsx
import React from 'react';
import { ScrollView, View, Text, StyleSheet } from 'react-native';
import ProtectedMediaDisplay from '../components/ProtectedMediaDisplay'; // Adjust path

export default function ProtectedMediaScreen() {
  // TODO: Upload these files to the 'protected/' prefix in your S3 bucket
  const imageFile = "protected/gs.jpg";
  const videoFile = "protected/002.mp4";
  const audioFile = "protected/Aws-Amplify-Al-tool-kit.wav"; // Use the EXACT name from S3

  return (
    <ScrollView style={styles.container}>
      <Text style={styles.title}>Protected Media</Text>

      <View style={styles.mediaItem}>
        <Text style={styles.mediaLabel}>Image:</Text>
        <ProtectedMediaDisplay path={imageFile} alt="Protected JPG" />
      </View>

      <View style={styles.mediaItem}>
        <Text style={styles.mediaLabel}>Video:</Text>
        <ProtectedMediaDisplay path={videoFile} alt="Protected MP4" />
      </View>

      <View style={styles.mediaItem}>
        <Text style={styles.mediaLabel}>Audio:</Text>
        <ProtectedMediaDisplay path={audioFile} alt="Protected WAV" />
      </View>

      {/* Add <ProtectedMediaDisplay> for private files here later, similar to web */}

    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 15 },
  title: { fontSize: 22, fontWeight: 'bold', marginBottom: 20, textAlign: 'center' },
  mediaItem: { marginBottom: 30 },
  mediaLabel: { fontSize: 16, fontWeight: '600', marginBottom: 8 },
});

Step 7: Testing & Verification

Upload Files: Manually upload test files (gs.jpg, 002.mp4, Aws-Amplify-Al-tool-kit.wav) to the protected/ prefix in S3. MATCH NAMES EXACTLY!
Run App: npx expo start, then open on your simulator or scan the QR code with Expo Go on your device.
Login: Use the Authenticator UI.
Navigate: Go to the "Protected Media" screen from the Home screen.
Verify: The image should load. The video should show controls and play. The audio section should have a "Play Audio" button that plays the sound.

🔧 Troubleshooting (React Native):

NoBucket Error: Check App.tsx Amplify configuration (Step 3). Is amplify_outputs.json imported correctly?
404 NotFound Error: File path/name mismatch between component prop and actual S3 object key. Check S3 Console carefully.
403 Forbidden Error: Login status, access rules (amplify/storage/resource.ts), backend deployment.
Media Playback Issues (expo-av):
Ensure expo-av is installed correctly (npx expo install expo-av).
Check device/simulator audio output.
Look for specific playback errors in the console/terminal.
Ensure URLs generated by getUrl are valid (test in a browser if possible).
Audio might require specific permissions or configuration on some devices (less common for simple playback).
Build/Runtime Errors: Check terminal output from npx expo start for specific errors.
This React Native (Expo) cheatsheet provides the equivalent setup for securely displaying S3 media. Remember that handling private files and uploads will require similar logic adaptations as shown in the detailed web tutorial (fetching identityId, constructing paths, using uploadData).