Read shared preferences from native apps

Sometimes it is necessary to migrate from native apps (e. g. iOS or Android) to a Flutter app. If there is already a user base, you do not want all users to lose their settings stored in the shared preferences once they update their app.

Let’s see why it’s not as simple as using the official SharedPreferences package to do the migration and what alternatives there are.

The first problem

How to store shared preferences in native apps

Shared preferences are (to the caller) nothing else but good old key-value-stores While Android reveals its underlying storage details to the developer, it is not that transparent on iOS. In fact, on Android, it is nothing else than a named XML file with the nodes having a name attribute and a value attribute.

On Android, if we do this:

SharedPreferences sharedPrefs = context.getSharedPreferences(
    "storage_name",
    Context.MODE_PRIVATE
);

sharedPrefs.putString('key_name', 'value');
editor.apply();

Choosing storage_name as the file name, key_name as the key, value as the value, it will result in this:

/data/data/my.package/shared_prefs/storage_name.xml

So the package name determines the main directory, then there is a shared_prefs directory we are not able to alter and then there is the storage_name.xml whose name is determined by the first argument we put in the getSharedPreferences() method.

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="key_name" value="value" />
</map>

Now if we look at what is inside that file, we notice that there is a root node named map. In it, we find one child node with a tag name string which is derived from the type of method we called (in this case sharedPrefs.putString()). The two attributes name and value contain both of the arguments we provided.

How to read shared preferences in Flutter

Now how to we access this data within our Flutter app?

Using the official SharedPreferences package, we can simply do this:

SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.getString('key_name');

We just create an instance of our SharedPreferences class and access the value knowing the type and key name by typing getString('key_name');. As simple as that.

No we are done, right? This is how we access the shared preferences previously stored on a native device, correct?

As you might have guessed, this would return null.

But why is that?

To understand what is happening, let us have a deeper look into the implementation of the plugin:

class SharedPreferences {
  SharedPreferences._(this._preferenceCache);

  static const String _prefix = 'flutter.';
  ...

  Future<bool> _setValue(String valueType, String key, Object value) {
    ArgumentError.checkNotNull(value, 'value');
    final String prefixedKey = '$_prefix$key';
    if (value is List<String>) {
      // Make a copy of the list so that later mutations won't propagate
      _preferenceCache[key] = value.toList();
    } else {
      _preferenceCache[key] = value;
    }
    return _store.setValue(valueType, prefixedKey, value);
  }
  ...
}

In fact, we find a very strange _prefix variable in the shared_preferences.dart. And this exact _prefix is directly prepended to the key. The issue here is that it is defined as a const variable, which leaves us no choice to alter or omit this value.

So when we access key_name the way we tried, instead of looking for this:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="key_name" value="value" />
</map>

The application actually looks for this:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="flutter.key_name" value="value" />
</map>

You might ask yourself why the developers decided to do that. I can not answer this question. All I can say is that it makes the process of accessing shared preferences that have not been stored by this plugin but by any software that does not know anything about prefixes (like native apps) unnecessarily hard.

External package to the rescue

The Flutter community is very active and most problems are already solved by existing packages on pub.dev so this common problem must be addressed by any of them, right?

Yes, sort of.

There is a package called Native Shared Preferences which is basically a copy of the Shared Preferences package that omits this prefix. So you are free to prepend it yourself or not if you want to access natively stored data.

Does this fix our problem?

Yes, but only partly.

The second problem

As I have mentioned above, when storing data on Android, you are required to define a file name which determines the name of the XML file the preferences are stored in. Yet, we are not asked to define this file name when reading the value in the Flutter app. So which file does it look for?

In the Shared Preferences Package, the file name is (like the _prefix) statically defined with the arbitrary value FlutterSharedPreferences.

Again, I do not know why this is the way it was implemented. Even if the developers wanted to keep the Shared Preferences stored by Flutter separate from anything else, they could have at least made these predefined values only the default values but changeable nonetheless.

So instead of looking for this file:

/data/data/my.package/shared_prefs/storage_name.xml

We can only read this file:

/data/data/my.package/shared_prefs/FlutterSharedPreferences.xml

The Native Shared Preferences package has a solution for that. It says:

The issue is that flutter add a prefix to the keys when read and write. So we can not read our old keys. Also for Android you can now specify name of your resource file in android/app/src/main/res/values/strings.xml

And this does indeed work. There is one problem, though. What if the Shared Preferences from my native app are spread across multiple XML files?

In fact, there is no solution for that. You can only choose one preferred file name.

If you only have a single XML file, you can stop right here, use the Native Shraed Preferences package and just define it like this:

<resources>
    ...
    <string name="flutter_shared_pref_name">your_preferred_file_name</string>
    ...
</resources>

Where your_preferred_file_name is the name of your XML file and put this content into your android/app/src/main/res/values/strings.xml

The solution

If you have multiple XML files or you do not want to rely on this package, you have to go for a custom solution. This involves the usage of platform channel.

Android

Let’s start with the implementation of the Kotlin code:

import android.content.*
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterActivity() {
    private val CHANNEL = "mypackage.com/shared_pref_migration"

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            when (call.method) {
                "getStringValue" -> {
                    val key: String? = call.argument<String>("key");
                    val file: String? = call.argument<String>("file");

                    when {
                        key == null -> {
                            result.error("KEY_MISSING", "Argument 'key' is not provided.", null)
                        }
                        file == null -> {
                            result.error("FILE_MISSING", "Argument 'file' is not provided.", null)
                        }
                        else -> {
                            val value: String? = getStringValue(file, key)
                            result.success(value)
                        }
                    }
                }
                else -> {
                    result.notImplemented()
                }
            }
        }
    }

    private fun getStringValue(file: String, key: String): String? {
        return context.getSharedPreferences(
                file,
                Context.MODE_PRIVATE
        ).getString(key, null);
    }
}

Basically, we use a when-statement (which is the Kotlin pendant to the switch statement you might know from other languages) to determine, which function to execute. In this case I only implemented the function for getting a String value. It is possible that the Shared Preferences XML file’s values have different data types, in which case you would have to implement separate functions for each case.

When the method property is equal to “getStringValue”, we read a String using the getString() method. We also read both of the arguments: key and file where key is the key of the value we have previously stored and file is the name of the XML file (which defaults to FlutterSharedPreferences in the Flutter context).

If one of the required arguments is not provided, we forward the error to the caller.

iOS

We continue by implementing the iOS part which requires us to write some Swift code:

import UIKit
import Foundation
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let platformChannel = FlutterMethodChannel(
      name: "mypackage.com/shared_pref_migration",
      binaryMessenger: controller.binaryMessenger
    )

    platformChannel.setMethodCallHandler({
      [weak self] (call: FlutterMethodCall, result: FlutterResult) -> Void in
      guard call.method == "getStringValue" else {
        result(FlutterMethodNotImplemented)
        return
      }

      if let args: Dictionary<String, Any> = call.arguments as? Dictionary<String, Any>,
        let number: String = args["key"] as? String, {
        self?.getStringValue(key: key, result: result)
        return
      } else {
        result(
          FlutterError.init(
            code: "KEY_MISSING",
            message: "Argument 'key' is not provided.", 
            details: nil
          )
        )
      }
    })

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}
    
private func getStringValue(key: String, result: FlutterResult) -> String? {
  let fetchedValue: String? = UserDefaults.object(forKey: key) as? String
  result(fetchedValue)
}

It’s basically the same code in a different language and environment. There is one difference though: on iOS we are not required to specify a file name. That’s because the system does not forward details about the underlying storage mechanism to the caller. So we are left with the only relevant argument: the key.

Usage

How do we call the platform code now?

Let’s say we want to read the user id, a string that was previously stored within the native app context.

  import 'package:flutter/services.dart';

  Future<String?> _getUserId() async {
    try {
      return await _getStringValue(_userIdKey, _userIdFile);
    } on PlatformException catch (exception) {
      print(exception);
      return null;
    }
  }

  Future<String?> _getStringValue(String key, String file) async {
    final MethodChannel methodChannel = MethodChannel('mypackage.com/shared_pref_migration');
    return methodChannel.invokeMethod('getStringValue', <String, dynamic>{
      'key': key,
      'file': file,
    });
  }

This is a simplified example as in the case of an error we just return null instead of properly handling it. But it should illustrate sufficiently how to call the platform channel.

The most important thing is that the channel name that is given to the constructor of the MethodChannel matches the channel name the native implementation listens to. Otherwise there will be no native code execution.

Conclusion

A rather common scenario – migrating shared preferences from native apps to Flutter apps – is surprisingly complicated. This is mainly man-made as it happens due to the fact that there are constant prefixes and file names involved. If the package owners of Shared Preferences just made these parts configurable, there would be no need to find a custom way around it.

However, the native implementation necessary to make it work is rather simple so the overall effort needed is acceptable.

If you like what you’ve read, feel free to support me:

🥗Buy me a salad

Leave a Comment