Monday, August 3, 2026

Cloning an app with AI

I had been using the Android app CloudPlayer to play my MP3 files on my Google Drive. The app was missing some features I wanted, and I was also curious about how easy AI makes it to clone an app, and how much this could threaten the indie-hacker ecosystem.

The features I wanted were an icon indicating downloaded songs next to each track name, the ability to remove individual tracks from the cache, and information about how much storage space was used and how much remained. So I decided to create DriveMP3 entirely with AI assistance:
I started by asking Gemini to create a specification based on CloudPlayer. I then asked Claude Code to create a version plan based on the specification. The initial version would only connect to Google Drive and display the MP3 files stored there. Then I asked Claude to implement each version. It managed to one-shot each version with only making small mistakes once or twice. After verifying that each version worked as expected, I asked Claude to implement the next one. I did not edit any of the generated Kotlin code. 

Each version took Claude about 15 minutes to implement and consumed roughly $13 worth of API tokens on average. The total API cost was $93. This is much more expensive than the cost of CloudPlayer (free with ads, $15 without ads), especially when you also count the two days I personally spent for design and testing.

Note that API tokens are the most expensive way to use Claude Code. The advantage of API credits is that they remain valid for one year. If you are using Claude for less than one app a month, API tokens are the way to go. If you are using it to create more apps, the other pricing plans will be cheaper.

AI-assisted cloning creates three potential threats for app developers: 
  1. Users who build personal alternatives.
  2. Hobbyists who clone a few apps each year for personal use and release their versions as open source (I am an example of that). 
  3. Businesses that systematically identify promising apps, reproduce their visible functionality, and compete through stronger distribution and marketing.
In this experiment, cloning the app cost more than $100 when the value of my own design and testing time was included. If your price is low enough that ordinary users will not bother searching for an open-source alternative, hobbyist clones may have little effect on your sales. Note that I am talking about a one time payment here, if you are using a subscription model, the incentives for cloning increase.

However, an app cloning business can use the $200/month plan to create an app every couple of days. Note that it also takes time to publish an app on Google Play. Let's assume the time you spent makes the total $300/app. If they market the clone effectively (which can cost an additional couple thousand dollars) and provide a comparable user experience, they may capture your potential customers. If your app is easy to clone, you might only be able to make money from it until it becomes popular and appears on the radar of cloning shops.

What can you do to make cloning harder? An AI can only clone what it can see (the client side) or what is easily predictable (standard business logic). Apps like CloudPlayer fall into this category. To build a defensible moat against AI-assisted clones, you need to shift your value proposition away from surface-level features and toward capabilities that are deeply embedded and difficult to replicate. This means you have to be an expert in a field with complex workflows and compliance requirements and have domain specific data. If your app has a social component (e.g. Slack), if you get lots of users quickly, a copycat can clone the code in a week, but they cannot clone the teams already communicating on it.

The age of casual apps that can be sold through word of mouth has come to an end for indie hackers. Defensibility now comes from better marketing, backend depth, proprietary data, network effects, distribution channels, and execution speed.

Wednesday, May 6, 2026

Database indexing

Binary search is O(log n) but it only works on sorted data. Suppose you have a customer database and want to find customers whose ages are between 25 and 30. If the database is naturally ordered by customer creation date, you would first need to sort the data by age before performing a binary search to locate the rows corresponding to ages 25 and 30. For a large database, sorting the entire dataset can be time consuming.

A better approach is to create indexes on specific columns. An index on the age column, for example, would contain only two fields: age and customer ID. Because this index is smaller than the full table, it is faster to sort and search. When a query for customers aged between 25 and 30 is executed, the database can perform a binary search (or similar efficient lookup) on the index to find the relevant customer IDs, and then use those IDs to retrieve the full records from the main table.

The downside of indexing is slower write performance. Each time a user creates an account or places an order, the database must update not only the main table but also all associated indexes. Having too many indexes can make write operations, such as clicking a “Save” button, feel slower. Additionally, indexes consume storage space, and at large scale, the total size of the indexes can even exceed that of the actual data.

Tuesday, July 15, 2025

How to write a software issue

If a software problem report or issue describes a large goal or vision, it should be documented as part of a concept of operations or a specification. If you can’t write clear acceptance criteria, or if a developer can’t reasonably complete it in 1–3 working days, it’s probably too big and needs to be split.

Large issues often depend on knowledge of many parts of the system, while smaller ones usually touch just one area. Smaller issues are easier to test, review, and merge. A smaller scope also makes it easier to prioritize and adjust based on feedback. 

Smaller, well-defined tasks are much better candidates as a “first issue” for a newcomer because they feel more approachable. It’s also much clearer when the work is done and what the expected result is. A newcomer can complete the work, submit a pull request, and get feedback quickly — which builds confidence and keeps them engaged.

Monday, June 2, 2025

Non-Functional Requirements

When building software, we define two types of requirements:

  1. Functional: What the system should do (e.g., login, payment).
  2. Non-functional: How well it should do it (e.g., speed, copy protection, security, scalability).

Without this distinction, you risk neglecting important aspects of system quality that don’t show up in features alone. You can implement a feature that works but still fails the user if it’s slow, insecure, or unreliable. Performance is considered non-functional because it affects user experience but doesn’t define what the system does.

It is similar to the frontend-backend layers but not exactly. Frontend-backend is about where things happen. Functional vs. non-functional is about what vs. how well. But there's overlap — non-functional requirements often live in the backend, yet both layers can have them.

In the beginning of a project, non-functional requirements are often forgotten because

  1. They’re abstract and harder to measure
  2. Not visible in UI

But ignoring them leads to rework, failure at scale, and unhappy users. Functional requirements get you a working, minimum viable product. Non-functional requirements make it usable, scalable, and successful.

Wednesday, May 21, 2025

In PHP, "false" can be true

All values loaded from an environment (.env) file using vlucas/phpdotenv are treated as strings, regardless of how they appear in the file. Even if your .env file contains IS_IN_SANDBOX_MODE = false, $_ENV['IS_IN_SANDBOX_MODE'] will be "false". The following condition would evaluate to true, even though the variable in the .env file is set to false, because "false" is a non-empty string and thus truthy in PHP:

if (IS_IN_SANDBOX_MODE) --> if ("false") --> true

This is a common gotcha when working with environment variables in PHP. To address this, you can use:

define('IS_IN_SANDBOX_MODE', filter_var($_ENV['IS_IN_SANDBOX_MODE'],
    FILTER_VALIDATE_BOOLEAN));

Note that FILTER_VALIDATE_BOOLEAN will return null for values like "True", "FALSE", "TRUE", etc. if (null) evaluates to false. It will return true for "true" (lowercase), "1", "on", or "yes". It will return false for "false" (lowercase), "0", "off", "no", or an empty string "".

Alternatively, my personal preference (because it makes the string conversion explicit):

define('IS_IN_SANDBOX_MODE', $_ENV['IS_IN_SANDBOX_MODE'] === 'true');

Or, when using it in a condition:

if (IS_IN_SANDBOX_MODE === 'true')

Wednesday, May 7, 2025

React Native Parameter Passing

When building mobile apps with React Native, we often face a decision about how to pass data between components - especially to screen components. Let's examine two common approaches and when to use each:

1. Direct Props Passing:

interface AddressInfoProps {
    cartItems: CartItem[];
    discountPrice: number;
}
const AddressInfo: React.FC<AddressInfoProps> = ({ cartItems, discountPrice }) => {
    // Component implementation
};

2. Navigation Route Parameters:

import { useRoute, RouteProp } from '@react-navigation/native';
type AddressInfoRouteProp = RouteProp<{
    AddressInfo: {
        cartItems: CartItem[];
        discountPrice: number;
    };
}, 'AddressInfo'>;
const AddressInfo: React.FC = () => {
    const route = useRoute<AddressInfoRouteProp>();
    const { cartItems, discountPrice } = route.params;
    // Component implementation
};

Option 1 is simpler, but it lacks built-in mechanisms to persist state when navigating back and forth between screens. You must always explicitly pass the parameters, meaning the developer is responsible for state persistence. Use it when the component is part of a larger component hierarchy where the parent already has the necessary data to pass down.

Use route parameters (Option 2) when the component represents a screen in your navigation flow—i.e., when backward and forward navigation is expected. With Option 2, the React Native navigation system handles state automatically. If the user navigates to another page and then presses the back button, the screen will re-render with its previous state.

Other options:

Context API: Best for data needed by many components at different nesting levels, but avoid for frequently changing data as it can cause unnecessary re-renders

State Management Libraries (Redux, MobX, Zustand, Jotai): Best for complex applications with lots of shared state and interactions between different parts of the app

URL Parameters: Best for web applications where maintaining bookmarkable state is important

Local Storage: Best for persisting data between app sessions like login status

Monday, May 5, 2025

Storing auth tokens on mobile

React Native AsyncStorage is a simple key-value storage system that saves data as plain text. This means that anyone with access to the device or its backups can potentially read your auth tokens, API keys, or other sensitive data if you use AsyncStorage.

Storing sensitive data unencrypted can also violate Google Play and AppStore guidelines which might result in your app being rejected. Reviewers might run basic security tests that could expose unencrypted token storage.

SecureStore is Expo's abstraction layer that leverages the native security infrastructure of both iOS and Android platforms. SecureStore automatically encrypts all data before storage and provides a unified API that works identically on both iOS and Android. While SecureStore has slightly more overhead due to encryption/decryption operations, the performance impact is negligible for typical use cases like token storage. The security benefits far outweigh any minor performance considerations.