Read-only lambda variable added to ArrayList cannot be removed

I will use my code to describe the issue. I want to add a clipboard change listener when a service starts, and remove it when the service is destroyed. My service:

class CopyListenerService : Service() {
    override fun onBind(p0: Intent?): IBinder? = null

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        clipboardManager.addPrimaryClipChangedListener(onPrimaryClipChangedListener)
        return START_STICKY
    }

    override fun onDestroy() {
        super.onDestroy()
        clipboardManager.removePrimaryClipChangedListener(onPrimaryClipChangedListener)
    }

    val clipboardManager by lazy { getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }
    val onPrimaryClipChangedListener = {
        Log.d(TAG, "Clip Item count: " + clipboardManager.primaryClip.itemCount)
        Unit
    }

    companion object {
        val TAG = "CopyListenerService"
    }
}

And the problem is that clipboardManager.removePrimaryClipChangedListener() fails to remove the listener.

Show the bytecode and decompile the source, I find that onPrimaryClipChangedListener is translated into a Function0 object and a new class CopyListenerServiceKt$sam$OnPrimaryClipChangedListener$15d0add3, which implements OnPrimaryClipChangedListener and wraps the onPrimaryClipChangedListener object, is generated. When I call clipboardManager.addPrimaryClipChangedListener(onPrimaryClipChangedListener), it actually creates a new instance of CopyListenerServiceKt$sam$OnPrimaryClipChangedListener$15d0add3. When removing the listener, it creates another new instance, and they are definitely not the same one, though they wraps the same onPrimaryClipChangedListener object.

So is it the intended behavior of Kotlin? If so, what’s the best practice to avoid such issue?

Here’s the decompiled java code:

// CopyListenerServiceKt$sam$OnPrimaryClipChangedListener$15d0add3.java
package com.perqin.copyshare;

import android.content.ClipboardManager.OnPrimaryClipChangedListener;
import kotlin.Metadata;
import kotlin.jvm.functions.Function0;
import kotlin.jvm.internal.Intrinsics;

@Metadata(
   mv = {1, 1, 6},
   bv = {1, 0, 1},
   k = 3
)
final class CopyListenerServiceKt$sam$OnPrimaryClipChangedListener$15d0add3 implements OnPrimaryClipChangedListener {
   // $FF: synthetic field
   private final Function0 function;

   CopyListenerServiceKt$sam$OnPrimaryClipChangedListener$15d0add3(Function0 var1) {
      this.function = var1;
   }

   // $FF: synthetic method
   public final void onPrimaryClipChanged() {
      Intrinsics.checkExpressionValueIsNotNull(this.function.invoke(), "invoke(...)");
   }
}
// CopyListenerService.java
package com.perqin.copyshare;

import android.app.Service;
import android.content.ClipboardManager;
import android.content.Intent;
import android.content.ClipboardManager.OnPrimaryClipChangedListener;
import android.os.IBinder;
import android.util.Log;
import kotlin.Lazy;
import kotlin.LazyKt;
import kotlin.Metadata;
import kotlin.TypeCastException;
import kotlin.Unit;
import kotlin.jvm.functions.Function0;
import kotlin.jvm.internal.DefaultConstructorMarker;
import kotlin.jvm.internal.PropertyReference1Impl;
import kotlin.jvm.internal.Reflection;
import kotlin.reflect.KProperty;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

@Metadata(
   mv = {1, 1, 6},
   bv = {1, 0, 1},
   k = 1,
   d1 = {"\u00006\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0002\n\u0002\u0018\u0002\n\u0002\b\u0005\n\u0002\u0018\u0002\n\u0002\u0010\u0002\n\u0002\b\u0003\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0002\b\u0002\n\u0002\u0010\b\n\u0002\b\u0005\u0018\u0000 \u00182\u00020\u0001:\u0001\u0018B\u0005¢\u0006\u0002\u0010\u0002J\u0014\u0010\u000e\u001a\u0004\u0018\u00010\u000f2\b\u0010\u0010\u001a\u0004\u0018\u00010\u0011H\u0016J\b\u0010\u0012\u001a\u00020\u000bH\u0016J\"\u0010\u0013\u001a\u00020\u00142\b\u0010\u0015\u001a\u0004\u0018\u00010\u00112\u0006\u0010\u0016\u001a\u00020\u00142\u0006\u0010\u0017\u001a\u00020\u0014H\u0016R\u001b\u0010\u0003\u001a\u00020\u00048FX\u0086\u0084\u0002¢\u0006\f\n\u0004\b\u0007\u0010\b\u001a\u0004\b\u0005\u0010\u0006R\u0017\u0010\t\u001a\b\u0012\u0004\u0012\u00020\u000b0\n¢\u0006\b\n\u0000\u001a\u0004\b\f\u0010\r¨\u0006\u0019"},
   d2 = {"Lcom/perqin/copyshare/CopyListenerService;", "Landroid/app/Service;", "()V", "clipboardManager", "Landroid/content/ClipboardManager;", "getClipboardManager", "()Landroid/content/ClipboardManager;", "clipboardManager$delegate", "Lkotlin/Lazy;", "onPrimaryClipChangedListener", "Lkotlin/Function0;", "", "getOnPrimaryClipChangedListener", "()Lkotlin/jvm/functions/Function0;", "onBind", "Landroid/os/IBinder;", "p0", "Landroid/content/Intent;", "onDestroy", "onStartCommand", "", "intent", "flags", "startId", "Companion", "production sources for module app"}
)
public final class CopyListenerService extends Service {
   @NotNull
   private final Lazy clipboardManager$delegate = LazyKt.lazy((Function0)(new Function0() {
      // $FF: synthetic method
      // $FF: bridge method
      public Object invoke() {
         return this.invoke();
      }

      @NotNull
      public final ClipboardManager invoke() {
         Object var10000 = CopyListenerService.this.getSystemService("clipboard");
         if(var10000 == null) {
            throw new TypeCastException("null cannot be cast to non-null type android.content.ClipboardManager");
         } else {
            return (ClipboardManager)var10000;
         }
      }
   }));
   @NotNull
   private final Function0 onPrimaryClipChangedListener = (Function0)(new Function0() {
      // $FF: synthetic method
      // $FF: bridge method
      public Object invoke() {
         this.invoke();
         return Unit.INSTANCE;
      }

      public final void invoke() {
         Log.d(CopyListenerService.Companion.getTAG(), "Clip Item count: " + CopyListenerService.this.getClipboardManager().getPrimaryClip().getItemCount());
      }
   });
   @NotNull
   private static final String TAG = "CopyListenerService";
   // $FF: synthetic field
   static final KProperty[] $$delegatedProperties = new KProperty[]{(KProperty)Reflection.property1(new PropertyReference1Impl(Reflection.getOrCreateKotlinClass(CopyListenerService.class), "clipboardManager", "getClipboardManager()Landroid/content/ClipboardManager;"))};
   public static final CopyListenerService.Companion Companion = new CopyListenerService.Companion((DefaultConstructorMarker)null);

   @Nullable
   public IBinder onBind(@Nullable Intent p0) {
      return null;
   }

   public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
      ClipboardManager var10000 = this.getClipboardManager();
      CopyListenerServiceKt$sam$OnPrimaryClipChangedListener$15d0add3 var10001 = new CopyListenerServiceKt$sam$OnPrimaryClipChangedListener$15d0add3;
      Function0 var10003 = this.onPrimaryClipChangedListener;
      if(this.onPrimaryClipChangedListener == null) {
         Object var10002 = null;
      } else {
         var10001.<init>(var10003);
      }

      var10000.addPrimaryClipChangedListener((OnPrimaryClipChangedListener)var10001);
      return 1;
   }

   public void onDestroy() {
      super.onDestroy();
      ClipboardManager var10000 = this.getClipboardManager();
      CopyListenerServiceKt$sam$OnPrimaryClipChangedListener$15d0add3 var10001 = new CopyListenerServiceKt$sam$OnPrimaryClipChangedListener$15d0add3;
      Function0 var10003 = this.onPrimaryClipChangedListener;
      if(this.onPrimaryClipChangedListener == null) {
         Object var10002 = null;
      } else {
         var10001.<init>(var10003);
      }

      var10000.removePrimaryClipChangedListener((OnPrimaryClipChangedListener)var10001);
   }

   @NotNull
   public final ClipboardManager getClipboardManager() {
      Lazy var1 = this.clipboardManager$delegate;
      KProperty var3 = $$delegatedProperties[0];
      return (ClipboardManager)var1.getValue();
   }

   @NotNull
   public final Function0 getOnPrimaryClipChangedListener() {
      return this.onPrimaryClipChangedListener;
   }

   @Metadata(
      mv = {1, 1, 6},
      bv = {1, 0, 1},
      k = 1,
      d1 = {"\u0000\u0014\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\n\u0002\u0010\u000e\n\u0002\b\u0003\b\u0086\u0003\u0018\u00002\u00020\u0001B\u0007\b\u0002¢\u0006\u0002\u0010\u0002R\u0014\u0010\u0003\u001a\u00020\u0004X\u0086D¢\u0006\b\n\u0000\u001a\u0004\b\u0005\u0010\u0006¨\u0006\u0007"},
      d2 = {"Lcom/perqin/copyshare/CopyListenerService$Companion;", "", "()V", "TAG", "", "getTAG", "()Ljava/lang/String;", "production sources for module app"}
   )
   public static final class Companion {
      @NotNull
      public final String getTAG() {
         return CopyListenerService.TAG;
      }

      private Companion() {
      }

      // $FF: synthetic method
      public Companion(DefaultConstructorMarker $constructor_marker) {
         this();
      }
   }
}


Yes, this is expected behavior. The easiest fix for this particular case is to perform the SAM conversion when the variable is declared:

val onPrimaryClipChangedListener: OnPrimaryClipChangedListener = { ... }

But why this is “expected”?

Which behavior would you expect instead? You have declared a variable of a function type, so we store an instance of a function type (which is different from the SAM type used in that particular context). This variable can be converted to multiple different SAM interfaces if it’s used in different contexts. How exactly would you expect Kotlin to manage instances of all those variables?

Ok, I understand, it’s about conversion. But this is not really intuitive, perhaps should be documented somewhere.

The behaviour is correct, but I can see how it is confusing. I see few cases where multiple sam conversions of a function would be valid. This is something the compiler or analysis tool (lint) could warn about.

Or maybe generated wrapper for function (CopyListenerServiceKt$sam$OnPrimaryClipChangedListener$15d0add3 in code above, for example) should override equals (and hashCode) to compare actual delegated function.