Yapay zeka dünyaca ünlü oyuncunun kariyerini bitirdi

Güney Kore, ünlü oyuncu Kim Soo-hyun’un kariyerini bitiren skandalla çalkalanıyor. YouTube’da paylaşılan reşit olmayan biriyle ilişki iddiaları, mesajlar ve ses kayıtlarının tamamen yapay zekayla üretildiği ortaya çıktı. Polis, içerik üreticisinin para kazanmak amacıyla bu sahtekarlığa başvurduğunu ve aktörün hayatını kararttığını açıkladı. Savcılığın tutuklama talep ettiği olayda mahkeme haftaya karar verecek.

Choosing the Right Treasure Map to Avoid Data Decay in Veltrix

The Problem We Were Actually Solving

We thought we were solving the classic event-sourcing problem – storing and replaying events from a distributed system to ensure data consistency. Sounds simple, right? In theory, yes, but in practice, it’s a minefield. The event store has to be designed to handle a high volume of events, and our system was built on top of a MongoDB instance with a collection optimized for writing events. Sounds okay, but what we didn’t realize was that our event schema would evolve rapidly due to changing business requirements.

What We Tried First (And Why It Failed)

Our initial approach was to store events in an unsharded, denormalized collection with a single field for each event attribute. We thought this would speed up queries and avoid costly joins, but we soon realized that our event volume would exceed MongoDB’s performance limits, causing our system to slow down significantly. To make matters worse, our unsharded collection caused uneven distribution of writes, leading to hotspots and eventual data inconsistencies.

The Architecture Decision

After several weeks of troubleshooting, we made a critical decision to switch to a sharded, normalized schema with separate collections for each event type and date range. This change allowed us to distribute write load more evenly among shards, reducing the risk of hotspots and ensuring consistent data across the system. We also decided to implement a caching layer to minimize the number of queries hitting the event store directly.

What The Numbers Said After

With the new schema in place, event write latency dropped from 250ms to 30ms, and we were able to handle 10 times the number of events per second without any issues. Our average query latency decreased from 200ms to 10ms, and we managed to reduce data inconsistencies to a negligible level. MongoDB’s performance monitoring showed that our write load was now evenly distributed across shards, and no single shard was experiencing heavy load.

What I Would Do Differently

If I were to redo this project, I would make sure to account for our event schema evolution from the beginning. Our original schema was too rigid, and we had to undergo a costly migration to adapt to the changing business requirements. In hindsight, I would have opted for a more flexible schema design that could accommodate growth and changes without requiring a major overhaul. We also over-engineered our caching layer, which introduced additional complexity without providing significant performance benefits. A simpler caching strategy would have served us better.

The truth is, our initial approach to building the Treasure Hunt Engine on Veltrix was flawed from the start. We didn’t account for the complexity of our event schema, and our system suffered the consequences. By sharing my story, I hope to educate other engineers about the importance of considering the long-term implications of their design decisions and the perils of premature optimization.

Migrating to Apache Iceberg: Strategies for Every Source System

Three paths to Iceberg: in-place migration, full rewrite, and shadow migration

This is Part 15, the final article of a 15-part Apache Iceberg Masterclass. Part 14 covered hands-on Dremio Cloud. This article covers the three migration strategies and how to execute a zero-downtime migration using the view swap pattern.

Most organizations do not start with Iceberg. They have years of data in Hive tables, data warehouses, CSV files, databases, and Parquet directories. Moving this data to Iceberg is not an all-or-nothing project. The best migrations happen incrementally, one dataset at a time, with no disruption to existing consumers.

Table of Contents

  1. What Are Table Formats and Why Were They Needed?
  2. The Metadata Structure of Current Table Formats
  3. Performance and Apache Iceberg’s Metadata
  4. Technical Deep Dive on Partition Evolution
  5. Technical Deep Dive on Hidden Partitioning
  6. Writing to an Apache Iceberg Table
  7. What Are Lakehouse Catalogs?
  8. Embedded Catalogs: S3 Tables and MinIO AI Stor
  9. How Iceberg Table Storage Degrades Over Time
  10. Maintaining Apache Iceberg Tables
  11. Apache Iceberg Metadata Tables
  12. Using Iceberg with Python and MPP Engines
  13. Streaming Data into Apache Iceberg Tables
  14. Hands-On with Iceberg Using Dremio Cloud
  15. Migrating to Apache Iceberg

Three Migration Strategies

Three paths to Iceberg: in-place migration, full rewrite, and shadow migration

1. In-Place Migration (Metadata Only)

In-place migration creates Iceberg metadata over existing Parquet or ORC files without copying or moving them. The data files stay exactly where they are; only new Iceberg metadata is created to track them.

Spark example:

CALL system.migrate('db.existing_hive_table')

This converts a Hive table to Iceberg by scanning its files and creating the Iceberg metadata tree (metadata.json, manifest list, manifest files) that references them. The Parquet files are untouched.

Pros: Fast. No data movement. The table becomes queryable as Iceberg immediately.

Cons: The existing file layout (sizes, partitioning, sort order) is inherited. If the original files are poorly organized, you inherit those problems. Requires the original files to be in Parquet or ORC format.

2. Full Rewrite (CTAS)

A full rewrite reads data from any source and writes it as a new Iceberg table with optimal partitioning and file sizes:

-- Spark
CREATE TABLE iceberg_catalog.analytics.orders
USING iceberg
PARTITIONED BY (day(order_date))
AS SELECT * FROM hive_catalog.legacy.orders

-- Dremio
CREATE TABLE analytics.orders
PARTITION BY (day(order_date))
AS SELECT * FROM legacy_source.public.orders

Pros: Best result. Optimal file sizes, correct sort order, proper partitioning. The table is perfectly organized from day one.

Cons: Requires reading and writing all data, which takes time and compute resources. The source system must be available during the migration.

3. Shadow Migration (Build and Swap)

Shadow migration builds the Iceberg table alongside the existing source, then swaps consumers from old to new when ready:

  1. Create a new Iceberg table with the desired schema and partitioning
  2. Backfill historical data from the legacy source
  3. Set up incremental sync to keep the Iceberg table current
  4. Validate data quality between old and new
  5. Swap consumer views from legacy to Iceberg

Pros: Zero downtime. Consumers never see a disruption. You can validate the migration before committing to it.

Cons: Temporarily doubles storage costs. Requires maintaining two copies during the transition.

Choosing the Right Strategy

Decision tree for selecting the right migration strategy based on downtime tolerance and layout changes

Source Recommended Strategy
Hive table (Parquet files) In-place migration, then compact
Data warehouse (Snowflake, Redshift) Full rewrite via Dremio federation
CSV/JSON files in S3 Full rewrite with COPY INTO
PostgreSQL/MySQL Full rewrite or shadow migration
Delta Lake tables In-place conversion or rewrite
Production system (no downtime) Shadow migration with view swap

The View Swap Pattern

The zero-downtime view swap pattern: views point to legacy first, then switch to Iceberg

The view swap pattern is the recommended approach for production migrations. It uses Dremio’s semantic layer to create an abstraction between consumers and the underlying data:

Phase 1: Federation

Create views in Dremio that point to the legacy data source:

CREATE VIEW analytics.orders AS
SELECT order_id, customer_id, order_date, amount, status, region
FROM postgres_source.public.orders

All consumers (dashboards, reports, notebooks) query through these views. They do not know or care where the data physically lives.

Phase 2: Build Iceberg

Create and populate the Iceberg table:

-- Create the Iceberg table
CREATE TABLE iceberg_data.analytics.orders (
    order_id BIGINT, customer_id BIGINT,
    order_date DATE, amount DECIMAL(10,2),
    status VARCHAR, region VARCHAR
) PARTITION BY (day(order_date))

-- Backfill from the legacy source
INSERT INTO iceberg_data.analytics.orders
SELECT * FROM postgres_source.public.orders

Phase 3: Validate

Compare the two datasets to confirm data integrity:

SELECT
  (SELECT COUNT(*) FROM postgres_source.public.orders) AS legacy_count,
  (SELECT COUNT(*) FROM iceberg_data.analytics.orders) AS iceberg_count

Beyond row counts, validate aggregates (total amounts, distinct customer counts) and spot-check individual records. A comprehensive validation script should compare:

  • Total row count
  • Column-level checksums or hash aggregates
  • Distinct value counts for key columns
  • Boundary values (MIN/MAX) for numeric and date columns
  • Sample of specific records matched by primary key

Only proceed to the swap after all validation checks pass.

Phase 4: Swap

Update the view to point to the Iceberg table:

CREATE OR REPLACE VIEW analytics.orders AS
SELECT order_id, customer_id, order_date, amount, status, region
FROM iceberg_data.analytics.orders

Consumers notice nothing. The view name is the same. The query interface is the same. But now the data is served from Iceberg with all of its advantages: time travel, hidden partitioning, metadata-driven pruning, and automatic optimization.

Migrating One Table at a Time

The view swap pattern enables incremental migration. You do not need to migrate everything at once:

  1. Week 1: Migrate the highest-value table (e.g., orders)
  2. Week 2: Migrate the next table (e.g., customers)
  3. Continue until all critical tables are on Iceberg

During the transition, Dremio’s federation queries legacy and Iceberg tables together. A join between a PostgreSQL table and an Iceberg table works the same as a join between two Iceberg tables. The migration is invisible to consumers.

Post-Migration Checklist

After migrating each table:

Common Migration Pitfalls

Migrating without testing query performance: Always benchmark critical queries against the new Iceberg table before switching production traffic. Iceberg’s partition layout and file organization affect performance, and a migration can make some queries faster but others slower if the partition strategy is wrong.

Skipping the validation phase: Data discrepancies between the old and new systems are more common than expected. Schema differences, timezone handling, null semantics, and data type precision can all cause subtle mismatches. Validate thoroughly.

Migrating everything at once: Large “big bang” migrations carry high risk. If something goes wrong, rolling back is complex and time-consuming. Migrate one table at a time, validate each one, and build confidence incrementally.

This completes the Apache Iceberg Masterclass. The series covered table formats, metadata, performance, partitioning, writes, catalogs, maintenance, tooling, and migration. For hands-on practice, start a Dremio Cloud trial and follow the workflow in Part 14.

Books to Go Deeper

Free Resources

Stop Reviewing Every Line of AI Code – Build the Trust Stack Instead

AI-generated code should be treated as third-party code. Same mental model we already use for libraries and dependencies. We don’t review every line of lodash, fastapi, or chi. We shouldn’t expect to review every line of AI-generated code either.

I argued this in my previous post. The natural follow-up question: okay, but what does that actually require? You can’t tell people “trust it like you trust open-source” without explaining what that trust is built on. This post is a first attempt at answering that.

We Already Have A Trust Framework. We Just Don’t Use It For This.

We trust open-source code we’ve never read. Every day, in every codebase. That trust didn’t come from any single tool. It came from a stack of agreements built up over decades. Semantic Versioning. Conventional Commits. Lockfiles. Changelogs. Module boundaries. License declarations. Package signing.

None of these are tools. They’re primitives. Foundational contracts about how to describe code, change, and intent in a way that humans, and the tools we build, can rely on.

If AI-generated code is just another kind of third-party code, the question is straightforward: which of those primitives carry over, which need new equivalents, and which are missing entirely? Tools will follow once we agree on the shape underneath. They always do. But the shape has to come first, because right now every team trying to build trust for AI-generated code is doing it in private, with their own conventions, and the result is a pile of point solutions that can’t compose.

So this is the question I want to look at. Not what tools we should build. What does the trust stack look like once we apply the OSS lens to AI-generated code?

What Made Open-Source Trustworthy

We don’t usually interrogate this. We just trust well-maintained libraries. But why?

Strip the open-source trust stack down to its primitives, the underlying contracts and not the tools built on top of them, and you get something like this:

Authorship. Every change has an author, a timestamp, and ideally a reason. Git history isn’t just a log, it’s an audit trail. You can follow a line of code back to the moment it was written and the person who wrote it.

Versioning as a contract. Semantic versioning isn’t just a numbering scheme. It’s a promise. A patch bump says “we fixed something, nothing breaks.” A major bump says “we changed the contract, you need to adapt.” It’s a communication primitive between maintainers and consumers.

Conventional commits as intent. Commit messages like fix:, feat:, chore: encode the intent of a change. They’re the foundation that changelogs are generated from. The changelog is how you understand what happened without reading every diff.

Behavioral contracts. Type signatures, documented APIs, interface definitions. These define what the code promises to do. They’re the spec that tests are written against and the boundary that consumers depend on.

Automated verification. Linters, type checkers, security scanners, coverage gates. Not one-off checks but enforced, repeatable gates that run on every change. The trust isn’t in any single test. It’s in the habit of verification.

Isolation. Code lives behind package boundaries. You can pin it, swap it, replace it. The blast radius is contained because the boundary is real.

None of these are tools. They’re agreements. The reason we can ship third-party code we haven’t read is that this stack of agreements is doing the work for us, every time, quietly, in the background.

The lens I want to apply to AI-generated code is exactly this. For each of these, what’s the equivalent for code that an agent generated inside our own repo? Where does it already exist? Where does it need to be built?

The Primitives AI-Generated Code Needs

1. Traceability

The open-source equivalent: Authorship. Author, commit timestamp, git blame.

What AI-generated code needs: Three things. A clear marker that this code was AI-generated. The human who approved it. And a link to the originating request. The originating request is whatever your team uses to describe a unit of work, a ticket, an issue, an RFC, a spec.

Without those three, nothing else in the stack has anything to attach to. You can’t version, audit, isolate, or post-mortem something you can’t identify. You can’t ask “which AI-generated module is involved in this incident” because the question doesn’t have a place to land.

This is also where ownership becomes real. You didn’t write it, but you approved it. Your name is on it. The originating request anchors the why, the human approver anchors the who. The marker is what lets all of it be queried as a class. Three items, all of them answerable today if we choose to make them answerable.

2. The Decision Log

The open-source equivalent: Conventional commits. Changelogs. Release notes.

What AI-generated code needs: A record of why this code was generated. What problem it was solving. What constraints the agent was given. What the intent was.

This is the one I’d argue is most under-served, and the one with the highest leverage. When AI-generated code does something unexpected six months from now, the question won’t be “what does this code do.” You can read it. The question will be “why was it written this way, and what was it trying to accomplish?” Without a decision log, that answer is gone.

The decision log doesn’t need to be elaborate. The original prompt or task description, the key constraints, the intent in plain language. Stored somewhere queryable. Attached to the change, the module, or the PR, not buried in a Slack thread that disappears in 90 days.

The open-source world solved this with conventional commits and changelogs. Imperfect, inconsistent, but present. The primitive exists. For AI-generated code, we haven’t even agreed it needs to.

3. Behavioral Contracts

The open-source equivalent: Type signatures, documented APIs, interface definitions.

What AI-generated code needs: The same thing, but written before the code, not inferred from it.

The direction matters. In the open-source version, the interface is defined first and code implements it. With AI generation, it’s easy to end up with code that works and an interface reverse-engineered to match it. That’s backwards. The contract is supposed to be the promise. If the implementation defines the promise, the contract isn’t doing any work.

The right approach is contract-first generation: you define the behavioral contract, and the AI produces code that satisfies it. The contract becomes the spec, the test anchor, and the documentation, all at once. It’s also what shifts human review from line-by-line to contract-level. You’re not reviewing the implementation. You’re reviewing the contract, and trusting the verification layer to enforce it. That’s exactly what we already do for third-party libraries. We don’t read lodash. We don’t read fastapi. We don’t read chi. We trust the contract.

4. Verification

The open-source equivalent: Linters, type checkers, security scanners, tests written by consumers against the public API.

What AI-generated code needs: Verification with three layers.

Deterministic. Linters, type checkers, static analysis, security scanners, schema validators. The parts of the verification stack that don’t have a bad day. They run, they pass or fail, no judgment call. For AI-generated code this matters more than for human-written code, not less. The human writer at least had context the linter didn’t have. The AI writer didn’t have that context either. The deterministic floor is the part of verification you can rely on without trusting the writer or the reviewer.

Contract-anchored. The contract is the source of truth, not the implementation. Tests that describe what the code does instead of what the contract demands aren’t really tests, they’re a self-portrait. This separation is something we get for free with a third-party library. The test author has the docs and the public API, the contract is the only thing the tests can lean on. With AI-generated code, we lose that the moment the implementation starts driving what the tests check.

Org-aware. Conventions. Internal tooling. Naming patterns. The relationships between services. The business invariants the contract never captured. The things human reviewers check today without thinking about it. The implementation gets checked against the org’s accumulated standards, not against itself. Without this layer, “we won’t review every line” turns into “we don’t catch the things humans currently catch.”

The point of all three layers, taken together, is that humans don’t have to review every line. The verification stack catches what reviewers catch today, deterministically where possible, against the contract where the contract is the spec, against the org’s standards everywhere else.

5. Isolation and Blast Radius

The open-source equivalent: Package boundaries, dependency injection, the principle that you should be able to swap a library without rewriting your codebase.

What AI-generated code needs: The same discipline, applied with intention.

A package boundary isn’t just an organizational convenience. It’s a blast-radius mechanism. When the package breaks, the rest of the system keeps working. When you want to replace it, you don’t have to rewrite everything that touched it. AI-generated code without a boundary is the opposite. It infiltrates. It couples to things it shouldn’t. By the time you realize part of it is wrong, it’s wrapped around half your codebase.

This isn’t really new. It’s an existing discipline applied inward. The same care we already apply to third-party code, isolated behind clear interfaces, swappable, replaceable, deletable, should extend to AI-generated modules. With the boundary in place, the operational story falls into place. Feature flags. Staged rollouts. Falling back to a known-good path. The boundary is the primitive. The rest is practice.

Informed Ownership

You don’t read every line of every library you import. But when one breaks, you fix it. You read the docs. You write tests against its behavior. You check the changelog. The level of understanding isn’t “I’ve read the source.” It’s “I know what this does, what it promises, and what to do when it doesn’t.”

That’s the level we should be aiming for with AI-generated code. You didn’t write it. You might not have reviewed every line. But it’s in your codebase. It’s yours. The trust stack is what makes that ownership real.

Where This Goes Next

This is a starting point, not a finished framework. Five primitives that I think need to exist before we can treat AI-generated code the way we already treat third-party code.

The questions I think are worth working on next:

  • Who defines the convention for marking AI-generated code and linking it back to a request and an approver? This feels like something that needs to be standardized across teams, not invented separately by every tool vendor.
  • What does a decision log actually look like in practice? A structured comment block? A linked spec? A generated summary from the prompt session? An entry in a separate metadata file?
  • How do we write behavioral contracts that are useful both as input to AI generation and as anchors for verification? Are existing type systems enough? Do we need something new?
  • What does the org-aware layer of verification actually look like? Does it live in CI? In the agent’s context? Both? Today’s verification stack stops at deterministic checks and contract tests, the layer that catches business invariants and org conventions doesn’t really exist yet.
  • What’s the right level of isolation? Module-level? Service-level? Function-level? When is it overkill?

If you have thoughts on any of these, or think I’m wrong about the list, I’d love to hear it. Find me at @sag1v.

Open-source gave us a trust framework we use every day without thinking about it. AI-generated code deserves the same.

Originally published on debuggr.io.

I write about software engineering, AI, and the things that interest me along the way. If this resonated with you, come visit debuggr.io for more.

Implementation of AI in mobile applications: Comparative analysis of On-Device and On-Server approaches on Native Android and Flutter

Hi everyone! Today I want to share practical experience in integrating machine learning models into mobile ecosystems. I recently completed the research and development of two MVP applications (on Native Android and Flutter), defended this project at an international conference and now I want to share my integration experience with you.

In this article, we will analyze in detail the difference between local and server AI computing, compare the implementation features of the native layer on Kotlin and the cross-platform layer on Dart, and also analyze non-obvious bugs that you may encounter when working with the camera and file system.

If you are interested in a specific platform, you can skip directly to the relevant section in the navigation below. The source code of both projects is open under the MIT license, links to repositories (GitHub/GitLab) can be found below.

Navigation:

  • Analysis of approaches (On-Device / On-Server)
  • Implementation on Native Android (Kotlin)
  • Implementation in Flutter (Dart)
  • Conclusions and links to projects

Part 1. On-Device vs On-Server: Architectural Choice

Выбор места деплоя мобильной модели — это всегда компромисс между ресурсами устройства, точностью вычислений и требованиями к безопасности.

Local approach (On-Device)

The model is deployed directly in the application sandbox and carries out inference (inference) locally, using the computing power of the CPU, GPU or specialized neural processors (NPU/TPU) of the smartphone. As an On-Device solution, I used Google ML Kit (Image Labeling SDK).

Pros:

  • Minimum ping (Latency): There are no network delays for the transmission of heavy media data.
  • Autonomy: Complete independence from the availability and quality of the Internet connection.
  • Confidentiality (Data Privacy): User data does not leave the device and is not transferred to third parties.

Cons:

  • Resource limitation: To prevent the application from burning out the battery and taking up gigabytes of memory, models are heavily quantized and cut down.
  • Reduced accuracy: Due to compression, the accuracy of lightweight models decreases (on average, the upper threshold of accuracy in basic classification tasks is about 61-65%)

Server approach (On-Server)

The model lives on a remote server and is accessible via the API. In my research, I used the Hugging Face Inference API (model google/vit-base-patch16-224).

Pros:

  • High accuracy: You can deploy heavy State-of-the-Art (SOTA) models, LLMs, or huge ensembles of neural networks with a colossal class base on the server.
  • Client unloading: The smartphone only fulfills the network request, does not heat up and does not waste power on complex mathematical calculations.

Cons:

  • Network dependence: No Internet – no AI.
  • Infrastructure and security costs: It is necessary to provide encryption of communication channels (TLS/SSL), protect API keys from reverse engineering and pay for server capacity

Part 2. Native implementation: Android

In a native application, the key task is to isolate heavy operations from the main interface thread (Main Thread) to avoid UI friezes and Application Not Responding (ANR) errors.

Working with API (On-Server)

To send POST requests (transmitting an image byte array), a combination of OkHTTP and Retrofit was used. Conversion of the server’s JSON response into strictly typed Kotlin data classes occurs automatically thanks to converters. The network call is encapsulated in a suspend function, which allows declarative control of asynchrony.

private const val TOKEN = "..."

data class PredictionResponse(val label: String, val score: Float)

interface HuggingFaceApi {

    @POST
    suspend fun postImage(
        @Url url: String,
        @Header("Authorization") token: String,
        @Body body: RequestBody
    ): List<PredictionResponse>
}
class ApiModel(){
    private val retrofit = Retrofit.Builder()
        .baseUrl("https://router.huggingface.co/hf-inference/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    val service: HuggingFaceApi = retrofit.create(HuggingFaceApi::class.java)

     suspend fun classifyImage(imageBitmap: Bitmap): Pair<String, Float>{
        return try {
            val imageToButeArray = compressBitmap((imageBitmap))
            val requestBody = imageToButeArray.toRequestBody("image/jpeg".toMediaTypeOrNull())

            val result = service.postImage(
                "models/google/vit-base-patch16-224",
                "Bearer $token",
                requestBody
            )
            val firstLabel = result.firstOrNull()
                ?: return "Не распознано" to 0f

            firstLabel.label to firstLabel.score



        }catch (e: Exception){
            Log.e("network", "Request failed", e)
            "Oшибка сервера" to 0f

        }
    }

}

Please note that before sending the image to the server we compress it and that we do not send too many bytes over the network

suspend fun compressBitmap(bitmap: Bitmap): ByteArray = withContext(Dispatchers.IO) {

    val stream = ByteArrayOutputStream()

    bitmap.compress(
        Bitmap.CompressFormat.JPEG,
        80,
        stream
    )

     stream.toByteArray()
}

Local inference (On-Device via ML Kit)

To work with the local ML Kit model, the library is configured via ImageLabelerOptions. We explicitly set setConfidenceThreshold(0.4f) – the model’s confidence threshold. By increasing this threshold, we cut off false positives, but force the algorithm to work more intensively.

To ensure stability and save RAM, the labeler object is initialized through the Kotlin delegate mechanism (by lazy):

private val labeler by lazy {
    val options = ImageLabelerOptions.Builder()
        .setConfidenceThreshold(0.4f)
        .build()
    ImageLabeling.getClient(options)
}

Why is by lazy here?

  1. Saving resources: An instance of the heavy ML Kit client is created not when the Activity is launched, but strictly at the time of the first request (when the user takes a photo).
  2. Context safety: Initialization is guaranteed to occur when the applicationContext is already fully formed by the operating system, which prevents NullPointerException from occurring.

The call to labeler.process(image) is asynchronous in nature (runs on Google’s Task API). To make it linear and MVVM-friendly, we wrap it with coroutines and wait for the execution result.

Architectural layer and flow control

In MainViewModel, all calls are wrapped in viewModelScope.launch. Depending on the position of the state switch (On-Device / On-Server), the required method is launched:

private fun classifyImage(bitmap: Bitmap?) {
        if (bitmap == null) return
        _uiState.update {
            it.copy(isLoading = true)
        }
        viewModelScope.launch {
            val startTime = System.currentTimeMillis()

            try {
                val (label, confidence) = if (_uiState.value.isOnDevice) {
                    mlKit.analyze(bitmap!!)
                } else {
                    apiModel.classifyImage(bitmap!!)
                }

                val duration = System.currentTimeMillis() - startTime

                _uiState.update {
                    it.copy(
                        classificationText = label,
                        confidenceValue = confidence,
                        timeTakenDuration = duration,
                        isLoading = false
                    )
                }
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(
                        classificationText = "Ошибка",
                        confidenceValue = 0f,
                        timeTakenDuration = 0L
                    )
                }
            }
        }

Working with the camera on Native Android

On the Native Android side, working with the camera looks concise thanks to the modern SDK CameraX. This is a Lifecycle-aware library: it knows when an Activity is minimized (onPause) or destroyed (onDestroy), and automatically releases camera resources and closes streams (ImageAnalysis / ImageCapture). We do not need to manually write the onDispose logic, and the result of a successful snapshot in the code can be a ready-made Bitmap object held in RAM, which eliminates unnecessary disk read-write operations.

Part 3. Cross-platform implementation: Flutter (Dart, Dio, Method Channels)

The Flutter application conceptually solves the same problems, but faces the specifics of Dart’s single-threaded architecture (Event Loop).

Network Inference (Dio + Futures)

To communicate with Hugging Face on Flutter, we used the Dio package. To prevent a heavy request and network packet processing from blocking the rendering of UI frames (after all, Dart runs on a single thread), we package the call into an asynchronous Future/Await model. While the network is chasing bytes, Event Loop calmly continues to render the interface.

final dio = Dio();

Future<List<dynamic>?> apiModel(String path) async {
  final Uint8List? imageBytes = await compressImage(path);

  if (imageBytes == null) {
    return null;
  }

  try {
    final response = await dio.post(
      "https://router.huggingface.co/hf-inference/models/google/vit-base-patch16-224",
      data: imageBytes,
      options: Options(
        headers: {
          "Authorization": "Bearer 'your_token'", // put your token from hugging face here
          "Content-Type": "image/jpeg",
        },
      ),
    );
    return response.data;


  } on DioException catch (e) {
    debugPrint("Error: $e");

  }
  return null;
}

Please note that before sending the image to the server we compress it and that we do not send too many bytes over the network

Future<Uint8List?> compressImage(String path) async {
  final Uint8List? result = await FlutterImageCompress.compressWithFile(
    path,
    quality: 80,
    format: CompressFormat.jpeg,
  );

  return result;
}

Native bridge: MethodChannel for ML Kit

Since there is no full-fledged direct SDK for ML Kit Image Labeling on Dart that provides the required level of customization, a Production approach is used: creating a MethodChannel (native bridge).

The Dart code acts as a client: it generates the predictOnDevice event and passes the path to the saved photo through the channel.

class NativeMlService {
  static const MethodChannel _channel = MethodChannel("mlkit_photo_analyze");

  static Future<Map> onDeviceMethod(String imagePath) async {
    final result = await _channel.invokeMethod(
      'imageLabeling',
      {'imagePath': imagePath},
    );
    return Map.from(result);
  }
}

On the Android side (MainActivity.kt) we catch this call through setMethodCallHandler. The same rules apply here: we deploy the coroutine on a background thread, process the image via ML Kit, but we transmit the response to result.success() strictly returning to the Main Thread, since the Flutter engine will not be able to accept data from the Android side thread.

 override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
            if(call.method == "imageLabeling"){
                val imagePath = call.argument<String>("imagePath")
                if (imagePath == null) {
                    result.error("ArgError", "Image path is null", null)
                    return@setMethodCallHandler
                }
                // Run ML inference on background thread to avoid blocking UI
                CoroutineScope(Dispatchers.IO).launch {
                    try {
                          // image processing and model calling....

                        // Return result on main thread
                        withContext(Dispatchers.Main){
                            result.success(response)
                        }
                    }....
//rest of the code on GitHub/ GitLab....

Camera in Flutter and Data Race (Race Condition)

The most difficult and interesting stage of developing the Flutter version was the integration of the camera plugin and debugging the interaction of file systems. Here two important differences from the native were revealed:

Manual Lifecycle Management: In Flutter, the developer must manually initialize the CameraController, catch available lenses (by selecting CameraLensDirection.back) and, most importantly, be sure to call _controller?.dispose() in the dispose() method of the widget.

If you forget, the camera will remain locked in the operating system, and other applications will not be able to open it.

Ghost File Problem (Race Condition):
The _controller?.takePicture() method in Flutter returns an XFile object that physically stores the snapshot in the device cache directory (image.path). This is where the classic data engineering race comes into play.

When Flutter happily reports that the photo has been taken and passes the path to the native code via MethodChannel, the native part (Kotlin) instantly tries to execute BitmapFactory.decodeFile(imagePath). But at the level of the Android operating system, the file in the cache may still be blocked – the stream of data writing from the camera buffer to the disk has not yet had time to physically close.

This was reflected in the logs as a hard crash:
E/ple.flutter_mvp: FrameInsert open fail: No such file or directory
The native code crashed, Bitmap returned null, and Flutter received an empty null reference instead of a data structure.

We get a similar error when we send a picture to the server because we are practically sending an empty picture

Solution to the problem:
To eliminate this data race, two-way protection was applied:

On the Dart (Provider) side: Before calling the native method/sending to the server, we artificially let the system “breathe out” by adding a micro-delay:

await Future.delayed(const Duration(milliseconds: 200));

This time is enough for the OS to complete disk operations.

Conclusion and conclusions

The conducted MVP study clearly proves: On-Device and On-Server approaches do not compete, but complement each other.

  • On-Server is indispensable for heavy computing (LLM, GPT, high-definition video processing).
  • On-Device is ideal for utilitarian tasks (scanning documents, recognizing simple objects, working in strict offline conditions).

In modern Production applications, the best practice is a hybrid approach**: fast primary output is done locally, and deep data validation is sent to the backend server.

Regarding the choice of platform: Native Android gives absolute control over resources, hardware and threads out of the box. Flutter, despite the limitations of single-threading Dart, with proper use of MethodChannel, compliance with the rules for dispatching coroutines in the native layer and taking into account file system timings, allows you to create responsive and productive AI applications.

GitHub

GitHub logo

RatRatatyu
/
mobile-ai-mvp

Two MVP applications demonstrating on-device and on-server AI model integration in Jetpack Compose (Android) and Flutter.

Mobile AI Integration: On-Device vs On-Server MVP Comparison

This repository contains two MVP applications developed for the International Scientific and Practical Conference
“Student Research: Challenges and Development Trends”.

🏫 Conference Information

  • Event: International Scientific and Practical Conference
    “Student Research: Challenges and Development Trends”

  • Organizers:
    Ministry of Education of the Republic of Kazakhstan,
    Department of Education of Aktobe Region,
    Aktobe Higher Humanitarian College,
    National Centre for Professional Development “Orleu”

  • Section:
    Science, Technology, and Digital Innovations

  • Date:
    May 22, 2026

📱 Project Overview

The project explores the architectural choice between running AI models directly on a smartphone (On-Device) versus processing them on a remote server (On-Server)

For this research, image classification was chosen as the primary use case to demonstrate the differences in performance and accuracy

🤖 Applied Models

On-Device: Powered by the ML Kit Image Labeling API from Google for local, real-time inference

On-Server: Powered by the Hugging Face google/vit-base-patch16-224…

GitLab


RatRatatyu / mobile-ai-mvp · GitLab

Two MVP applications demonstrating on-device and on-server AI model integration in Jetpack Compose (Android) and Flutter.

favicon
gitlab.com

I would like to note that I am just developing and learning in this direction, so perhaps my conclusions may be inaccurate, or the descriptions may not be entirely correct, so I will be grateful if you point out my mistakes in the comments, and I will also be glad if you put stars in GitHub and GitLab if the projects are useful to you