Month: August 2022

  • App Store Server Notifications  V2 with JWT, PHP and Laravel

    App Store Server Notifications V2 with JWT, PHP and Laravel

    App Store Server Notifications

    App Store Server Notifications is a server-to-server service that notifies you in real-time when the status of in-app purchases and refunds changes. Use the data in the notifications to update your user-account database, and to monitor and respond to in-app purchase refunds.

    App offering subscription based products must use App Store Server Notification to verify the purchase, renew the subscription, cancel the subscription, and more.

    App Store Server Notification Version 1 vs Version 2

    There are two types of App Store Server Notification, version 1 and version 2. Version 1 is already deprecated and is more likely to be discontinued in the near future.

    This article only covers Version 2.

    My Intentions

    My main goal is to get originalTransactionId from the notification data then when a subscription gets Expired or Refunded, it will downgrade the user subscription status.

    App Store Configuration

    User purchases we verify Apple’s receipt and we save originalTransactionId in our database. So each user has originalTransactionId and we can find the user with this originalTransactionId. You can read more about this here Auto-renewable subscriptions with SwiftUI

    Make sure you set your app store notification URL on the AppStoreConnect. Choose Version 2 Notifications.

    App Store Server Notifications  V2 with JWT, PHP and Laravel

    Each data will look like this, call it signedPayload.

    App Store Server Notifications  V2 with JWT, PHP and Laravel

    The signedPayload object is a JWS representation. To get the transaction and subscription renewal details from the notification payload, process the signedPayload as follows:

    1. Parse signedPayload to identify the JWS header, payload, and signature representations.
    2. Base64 URL-decode the payload to get the responseBodyV2DecodedPayload. The decoded payload contains the notificationType , subtype, other notification metadata, and a data object.
    3. The data object contains a signedTransactionInfo (JWSTransaction) and depending on the notification type, a signedRenewalInfo (JWSRenewalInfo). Parse and Base64 URL-decode these signed JWS representations to get transaction and subscription renewal details.

    Each of the signed JWS representations, signedPayloadsignedTransactionInfo, and signedRenewalInfo, have a JWS signature that you can validate on your server. Use the algorithm specified in the header’s alg parameter to validate the signature. For more information about validating signatures, see the JSON Web Signature (JWS) IETF RFC 7515 specification.

    Hopefully, you are already getting this data. Now let’s get originalTransactionId from this. We will do this without any 3rd party library first to understand the process.

    First thing, I will download the Apple root certificate and make it.PEM file from it.

    Download the certificate https://www.apple.com/certificateauthority/AppleRootCA-G3.cer

    Get .PEM file from it, on your Mac Terminal

    openssl x509 -in AppleRootCA-G3.cer -out apple_root.pem

    For testing purposes, I’m loading apple_root.pem and my signedPayload from a file called notification.json (replace it with file_get_contents(‘php://input’);) and then decoding the signedPayload. signedPayload has three parts, separated by .(dot), line 13.

    The first part is the header, the Second part is the body (payload) and the Third part is the signature. The header has an algorithm and x5c, x5c has three elements. Certificate, intermediate certificate, and root certificate. We can verify the certificate in two steps. Once the verification is completed we know we have signedPayload from Apple.

    Finally, decode the payload again and get the originalTransactionId from line 56 to 65.

    Server Side

    For this article, our production server URL looks like inafiz.com/jwt.php. You can get whatever Apple sends you and write a log in your server if you are interested.

    $appleData = file_get_contents('php://input');
    $file = fopen(
        "/var/www/html/appstore_prod.log", "a"
    );
    fwrite($file, $appleData);
    fclose($file);
    

    Without any 3rd party library(not recommended).

    <?php
    
    ini_set('display_errors', 1);
    error_reporting(E_ALL);
    
    // Download the certificate -> https://www.apple.com/certificateauthority/AppleRootCA-G3.cer
    // Convert it to .PEM file, run on macOS terminal ->  ```bash openssl x509 -in AppleRootCA-G3.cer -out apple_root.pem```
    
    $pem = file_get_contents('apple_root.pem');
    $data = file_get_contents('notification.json'); // replace with file_get_contents('php://input');
    $json = json_decode($data);
    
    $header_payload_secret = explode('.', $json->signedPayload);
    
    //------------------------------------------
    // Header
    //------------------------------------------
    $header = json_decode(base64_decode($header_payload_secret[0]));
    $algorithm = $header->alg;
    $x5c = $header->x5c; // array
    $certificate = $x5c[0];
    $intermediate_certificate = $x5c[1];
    $root_certificate = $x5c[2];
    
    $certificate =
          "-----BEGIN CERTIFICATE-----\n"
        . $certificate
        . "\n-----END CERTIFICATE-----";
    
    $intermediate_certificate =
          "-----BEGIN CERTIFICATE-----\n"
        . $intermediate_certificate
        . "\n-----END CERTIFICATE-----";
    
    $root_certificate =
          "-----BEGIN CERTIFICATE-----\n"
        . $root_certificate
        . "\n-----END CERTIFICATE-----";
    
    //------------------------------------------
    // Verify the notification request   
    //------------------------------------------
    if (openssl_x509_verify($intermediate_certificate, $root_certificate) != 1){ 
        echo 'Intermediate and Root certificate do not match';
        exit;
    }
    
    // Verify again with Apple root certificate
    if (openssl_x509_verify($root_certificate, $pem) == 1){
        //------------------------------------------
        // Payload
        //------------------------------------------
        // https://developer.apple.com/documentation/appstoreservernotifications/notificationtype
        // https://developer.apple.com/documentation/appstoreservernotifications/subtype
        $payload = json_decode(base64_decode($header_payload_secret[1]));
        $notificationType = $payload->notificationType;
        $subtype = $payload->subtype;
    
        if ($notificationType == "EXPIRED" || $notificationType == "REFUND") {
            $transactionInfo = $payload->data->signedTransactionInfo;
            $ti = explode('.', $transactionInfo);
            
            $data = json_decode(base64_decode($ti[1]));
            var_dump($data); // this will contain our originalTransactionId
        }
    } else {
        echo 'Header is not valid';
        exit;
    }

    Using firebase/php-jwt in composer (recommended), big difference is to use the public key to decode the payload using JWT.

    composer require firebase/php-jwt
    <?php
    
    ini_set('display_errors', 1);
    error_reporting(E_ALL);
    
    // No need these 3 lines for Laravel
    require_once './vendor/firebase/php-jwt/src/JWT.php';
    require_once './vendor/firebase/php-jwt/src/JWK.php';
    require_once './vendor/firebase/php-jwt/src/Key.php';
    
    use Firebase\JWT\JWT;
    use Firebase\JWT\Key;
    
    // Download the certificate -> https://www.apple.com/certificateauthority/AppleRootCA-G3.cer
    // Convert it to .PEM file, run on macOS terminal ->  ```bash openssl x509 -in AppleRootCA-G3.cer -out apple_root.pem```
    
    $pem = file_get_contents('apple_root.pem');
    
    $data = file_get_contents('notification.json');  // replace with file_get_contents('php://input');
    $json = json_decode($data);
    
    $header_payload_secret = explode('.', $json->signedPayload);
    
    //------------------------------------------
    // Header
    //------------------------------------------
    $header = json_decode(base64_decode($header_payload_secret[0]));
    $algorithm = $header->alg;
    $x5c = $header->x5c; // array
    $certificate = $x5c[0];
    $intermediate_certificate = $x5c[1];
    $root_certificate = $x5c[2];
    
    $certificate =
          "-----BEGIN CERTIFICATE-----\n"
        . $certificate
        . "\n-----END CERTIFICATE-----";
    
    $intermediate_certificate =
          "-----BEGIN CERTIFICATE-----\n"
        . $intermediate_certificate
        . "\n-----END CERTIFICATE-----";
    
    $root_certificate =
          "-----BEGIN CERTIFICATE-----\n"
        . $root_certificate
        . "\n-----END CERTIFICATE-----";
    
    //------------------------------------------
    // Verify the notification request   
    //------------------------------------------
    
    if (openssl_x509_verify($intermediate_certificate, $root_certificate) != 1){ 
        echo 'Intermediate and Root certificate do not match';
        exit;
    }
    
    // Verify again with Apple root certificate
    if (openssl_x509_verify($root_certificate, $pem) == 1){
        $cert_object = openssl_x509_read($certificate);
        $pkey_object = openssl_pkey_get_public($cert_object);
        $pkey_array = openssl_pkey_get_details($pkey_object);
        $publicKey = $pkey_array['key'];
    
        //------------------------------------------
        // Payload
        //------------------------------------------
        $payload = json_decode(base64_decode($header_payload_secret[1]));
        $notificationType = $payload->notificationType;
    
        //if ($notificationType == "EXPIRED" || $notificationType == "REFUND") {
            $transactionInfo = $payload->data->signedTransactionInfo;
            $signedRenewalInfo = $payload->data->signedRenewalInfo;
    
            $transactionDecodedData = JWT::decode($transactionInfo, new Key($publicKey, $algorithm));
            var_dump($transactionDecodedData->originalTransactionId);
            echo "========================================";
            $signedRenewalDecodedData = JWT::decode($signedRenewalInfo, new Key($publicKey, $algorithm));
            var_dump($signedRenewalDecodedData);
        //}
    
    } else {
        echo 'Header is not valid';
        exit;
    }

    Download the source code

    Conclusion

    In this article we have learned how to configure and implement App Store Server Notification version 2 in PHP and Laravel.

    Spread the love
  • How can I add item number on the cart icon on AppBar icon in Flutter

    Having a shopping cart icon on the right side of the AppBar on Flutter it’s not that hard, you can use basically a stack widget and inside the stack get IconButton and Positioned widgets.

    Here is the code and screenshot

    appBar: AppBar(
            // Here we take the value from the MyHomePage object that was created by
            // the App.build method, and use it to set our appbar title.
            title: Text(appBarTitle),
            actions: [
              Stack(
                children: [
                  IconButton(
                    onPressed: () {},
                    icon: const Icon(
                      Icons.shopping_cart_rounded,
                      size: 30,
                    ),
                  ),
                  Positioned(
                    top: 4,
                    right: 6,
                    child: Container(
                      height: 22,
                      width: 22,
                      decoration: const BoxDecoration(
                        shape: BoxShape.circle,
                        color: Colors.purple,
                      ),
                      child: const Center(
                          child: Text(
                        "2",
                        style: TextStyle(
                          fontSize: 12,
                          fontWeight: FontWeight.bold,
                        ),
                      )),
                    ),
                  ),
                ],
              ),
            ],
          )
    Spread the love
  • Download PDF in Flutter using Java on Android

    Downloading PDF file in the user download directory on Android is a very common feature, a lot of app uses PDF viewing and downloading feature.

    I hoped that flutter plugins could solve my problem but they didn’t. Fortunately, it was working great on iOS but it was not on android. So I decided to use my old java code in Flutter using Method Channel.

    First I tried two Flutter plugins one for permission and one for creating a directory.

    flutter pub add path_provider
    flutter pub add permission_handler

    Request permission was very easy and it worked on both iOS and Android

    Future<bool> _requestWritePermission() async {
        await Permission.storage.request();
        var r = await Permission.storage.request();
    
        return r.isGranted;
      }

    Since it was working on iOS, I decided to leave that code and separate platform code by importing ‘dart:io’ show Platform. Use this code on your onTap action.

    static const channel = MethodChannel('com.flutterframework/test');
    
    bool hasPermission = await _requestWritePermission();
    
    if (hasPermission) {
      if (Platform.isAndroid) {
        final int downloader = await channel.invokeMethod(
          'download',
          <String, String>{'title': title, 'pdf': pdf},
        );
        print(downloader);
      }
    
      if (Platform.isIOS) {
        var dir = await getApplicationDocumentsDirectory();
        if (dir != null) {
          String saveName = "$title.pdf";
          String savePath = "${dir.path}/$saveName";
          print(savePath);
    
          try {
            await Dio().download(pdf, savePath,
                onReceiveProgress: (received, total) {
              if (total != -1) {
                var percentage = (received / total * 100);
                print(percentage);
                if (percentage >= 100.0) {
                  ScaffoldMessenger.of(context).showSnackBar(
                      snackMessage(
                          'Download completed, please open Files app.'));
                }
              }
            });
            print("File is saved to download folder.");
          } on DioError catch (e) {
            ScaffoldMessenger.of(context).showSnackBar("Download error");
          }
        }
      }
    } else {
      ScaffoldMessenger.of(context).showSnackBar(filePermissionError);
    }
    

    Finally, the Java code, create a Java class in your main directory.

    package your_package;
    
    import android.app.DownloadManager;
    import android.content.Context;
    import android.net.Uri;
    import android.os.Build;
    import android.os.Environment;
    
    public class DownloadHelper {
        public static void downloadPDF(Context context, String title, String pdfUrl) {
            try {
                DownloadManager.Request request = new DownloadManager.Request(Uri.parse(pdfUrl));
                request.setDescription("MyApp");
                request.setTitle(title);
                // in order for this if to run, you must use the android 3.2 to compile your app
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
                    request.allowScanningByMediaScanner();
                    request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
                }
    
                if (Build.VERSION.SDK_INT <= 17) {
                    request.setDestinationInExternalFilesDir(context, "Documents", "MyFolder/" + title + ".pdf");
                } else {
                    request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "MyFolder/" + title + ".pdf");
                }
    
                // get download service and enqueue file
                DownloadManager manager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
                manager.enqueue(request);
            } catch (IllegalArgumentException e) {
                e.printStackTrace();
            }
        }
    }
    

    Call that Java code from MainActivity, my MainActivity in Kotlin

    class MainActivity: FlutterActivity() {
        private val CHANNEL = "com.flutterframework/test";
        private lateinit var channel: MethodChannel
    
        override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
            super.configureFlutterEngine(flutterEngine)
            channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            channel.setMethodCallHandler { call, result ->
                if (call.method == "download") {
                    val pdfTitle = call.argument<String>("title")
                    val pdfUrl = call.argument<String>("pdf")
                    Log.d("TAG", pdfTitle.toString());
                    Log.d("TAG", pdfUrl.toString());
    
                    DownloadHelper.downloadPDF(this, pdfTitle, pdfUrl);
                }
            }
        }
    }

    Download the complete project on GitHub

    Spread the love