본문 바로가기

Book/안드로이드 프로그래밍 Next Step(일부 공개)

[안드로이드 프로그래밍 Next Step] Chapter 8. Broadcast Receiver

Chapter 8. Broadcast Receiver

Broadcast Receiver는 Observer Pattern을 안드로이드에서 구현한 방식이다. Observer Pattern에서는 일대다 관계에서 직접 호출하지 않고, 인터페이스를 통한 느슨한 결합을 통해 옵저버를 register/unregister하는 방법을 제공한다. 이 방식은 BroadcastReceiver에서도 마찬가지이다. BroadcastReceiver는 바로 Observer이고, 이벤트는 sendBroadcast()에 전달되는 Intent이다.
Context에는 registerReceiver()와 unregisterReceiver() 메서드가 있는데, 여러 컴포넌트(Activity, Service, Application)에서 사용될 수 있다. 각 컴포넌트는 실행 중인 상태에서 Broadcast를 받으려고 할 때 Broadcast Receiver를 등록한다.

Broadcast Receiver 구현


BroadcastReceiver에는 추상 메서드가 onReceive() 하나뿐이고 이 메서드를 구현하면 된다.

12345678910111213141516171819202122232425262728293031323334353637383940414243444546
public abstract class BroadcastReceiver {
    
    ...
    
    /**
     * This method is called when the BroadcastReceiver is receiving an Intent
     * broadcast.  During this time you can use the other methods on
     * BroadcastReceiver to view/modify the current result values.  This method
     * is always called within the main thread of its process, unless you
     * explicitly asked for it to be scheduled on a different thread using
     * {@link android.content.Context#registerReceiver(BroadcastReceiver,
     * IntentFilter, String, android.os.Handler)}. When it runs on the main
     * thread you should
     * never perform long-running operations in it (there is a timeout of
     * 10 seconds that the system allows before considering the receiver to
     * be blocked and a candidate to be killed). You cannot launch a popup dialog
     * in your implementation of onReceive().
     *
     * <p><b>If this BroadcastReceiver was launched through a <receiver> tag,
     * then the object is no longer alive after returning from this
     * function.</b> This means you should not perform any operations that
     * return a result to you asynchronously. If you need to perform any follow up
     * background work, schedule a {@link android.app.job.JobService} with
     * {@link android.app.job.JobScheduler}.
     *
     * If you wish to interact with a service that is already running and previously
     * bound using {@link android.content.Context#bindService(Intent, ServiceConnection, int) bindService()},
     * you can use {@link #peekService}.
     *
     * <p>The Intent filters used in {@link android.content.Context#registerReceiver}
     * and in application manifests are <em>not</em> guaranteed to be exclusive. They
     * are hints to the operating system about how to find suitable recipients. It is
     * possible for senders to force delivery to specific recipients, bypassing filter
     * resolution.  For this reason, {@link #onReceive(Context, Intent) onReceive()}
     * implementations should respond only to known actions, ignoring any unexpected
     * Intents that they may receive.
     *
     * @param context The Context in which the receiver is running.
     * @param intent The Intent being received.
     */
    public abstract void onReceive(Context context, Intent intent);

    ...
}

BroadcastReceiver는 ContentProvider와 마찬가지로 ContextWrapper 하위 클래스가 아니다. 그렇지만 Context는 전달되므로 startService(), startActivity() 외에 sendBroadcast()를 다시 호출할 수도 있다.

Broadcast 발생 시 BroadcastReceiver를 거쳐서 Service나 Activity 시작


특정 이벤트가 발생할 때, sendBroadcast()를 통해서 Broadcast가 전달되고, 이때 화면을 띄우려면 BroadcastReceiver의 onReceive() 메서드에서 startActivity()를 실행한다. UI가 없는 내부 작업이 필요하다면 startService()를 실행한다.

onReceive() 메서드는 Main Thread에서 실행


onReceive() 메서드는 Main Thread에서 실행되므로 시간 제한이 있다. 10초(Foreground)/1분(Background: 기본) 내에 onReceive() 메서드는 실행을 마쳐야 한다. 10초/1분이 넘으면 ANR이 발생한다.
하나의 앱에서 단일 이벤트에 여러 BroadcastReceiver가 등록되어 있다면 여러 BroadcastReceiver의 onReceive() 메서드가 순차적으로 하나씩 실행되기 때문에 UI 동작에 문제가 생길 수 있다.

onReceive()에서 Toast 띄우기는 문제가 있음


question

BroadcastReceiver에서 Toast를 띄우면 잘 동작할까?

answer

동작할 수도 있고 아닐 수도 있다. Toast는 비동기 동작이다. 앱이 Foreground Process라면 Toast는 정상적으로 잘 동작한다. 하지만 Background Process이거나 앱에서 실행 중인 컴포넌트가 BroadcastReceiver밖에 없다면, onReceive() 메서드가 끝나자마자 프로세스 우선순위에 밀려서 프로세스가 종료될 수 있다.

onReceive()에서 registerReceiver()나 bindService() 메서드 호출이 안 됨


onReceive() 메서드에 Context가 전달되지만, Context의 메서드인 registerReceiver()나 bindService()를 호출하면 Runtime Error를 발생시킨다.
onReceive()에 전달된 Context는 구체적으로 ContextWrapper인 Application을 다시 감싼 ReceiverRestrictedContext 인스턴스이다. ReceiverRestrictedContext는 ContextImpl의 내부 클래스이면서 COntextWrappter를 상속하고 registerReceiver()와 bindService()를 오버라이드해서 예외를 발생하게 한 것이다.

API 52 기준으로 확인 했을때, registerReceiver() 메서드는 동작함
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
class ReceiverRestrictedContext extends ContextWrapper {
    ReceiverRestrictedContext(Context base) {
        super(base);
    }

    @Override
    public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
        return registerReceiver(receiver, filter, null, null);
    }

    @Override
    public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter,
            String broadcastPermission, Handler scheduler) {
        if (receiver == null) {
            // Allow retrieving current sticky broadcast; this is safe since we
            // aren't actually registering a receiver.
            return super.registerReceiver(null, filter, broadcastPermission, scheduler);
        } else {
            throw new ReceiverCallNotAllowedException(
                    "BroadcastReceiver components are not allowed to register to receive intents");
        }
    }

    @Override
    public Intent registerReceiverAsUser(BroadcastReceiver receiver, UserHandle user,
            IntentFilter filter, String broadcastPermission, Handler scheduler) {
        if (receiver == null) {
            // Allow retrieving current sticky broadcast; this is safe since we
            // aren't actually registering a receiver.
            return super.registerReceiverAsUser(null, user, filter, broadcastPermission, scheduler);
        } else {
            throw new ReceiverCallNotAllowedException(
                    "BroadcastReceiver components are not allowed to register to receive intents");
        }
    }

    @Override
    public boolean bindService(Intent service, ServiceConnection conn, int flags) {
        throw new ReceiverCallNotAllowedException(
                "BroadcastReceiver components are not allowed to bind to services");
    }
}

/**
 * Common implementation of Context API, which provides the base
 * context object for Activity and other application components.
 */
class ContextImpl extends Context {
    ...
        
}


Broadcast Receiver 등록


BroadcastReceiver를 등록하는 방식은 statically publish과 dynamically register 2가지가 있다.

Statically publish Boradcast Receiver


statically publish는 AndroidManifest.xml에 BroadcastReceiver를 추가하는 것이다. statically publish으로 만든 receiver는 broadcast가 발생하면 항상 반응한다. 주로 시스템 이벤트를 받을 때 많이 사용하는 것으로 앱이 실행 중이지 않더라도 프로세스가 뜨고서 이벤트를 처리한다.

example

1234567
<reciever android:name=".os.SmsMessageReceiver">
    <intent-filter>
        <action android:name="android.provider.Telephony.SMS_RECEIVED" />
    </intent-filter>
</receiver>

 외부 프로세스의 이벤트를 받는 BroadcastReceiver를 만들 때가 많기 때문에 샘플처럼 
 intent-filter를 추가해서 암시적 인텐트를 전달 받는다. 하지만 로컬 프로세스에서만
 사용하는 경우에는 intent-filter를 넣지 않고 명시적 인텐트를 전달 받을 수 있다.


Intent 클래스에 정의된 Broadcast Action


123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
// ---------------------------------------------------------------------
// ---------------------------------------------------------------------
// Standard intent broadcast actions (see action variable).

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_SCREEN_OFF = "android.intent.action.SCREEN_OFF";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_SCREEN_ON = "android.intent.action.SCREEN_ON";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_DREAMING_STOPPED = "android.intent.action.DREAMING_STOPPED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_DREAMING_STARTED = "android.intent.action.DREAMING_STARTED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_USER_PRESENT = "android.intent.action.USER_PRESENT";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_TIME_TICK = "android.intent.action.TIME_TICK";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_TIME_CHANGED = "android.intent.action.TIME_SET";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_DATE_CHANGED = "android.intent.action.DATE_CHANGED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_TIMEZONE_CHANGED = "android.intent.action.TIMEZONE_CHANGED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_CLEAR_DNS_CACHE = "android.intent.action.CLEAR_DNS_CACHE";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_ALARM_CHANGED = "android.intent.action.ALARM_CHANGED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_LOCKED_BOOT_COMPLETED = "android.intent.action.LOCKED_BOOT_COMPLETED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
@BroadcastBehavior(includeBackground = true)
public static final String ACTION_BOOT_COMPLETED = "android.intent.action.BOOT_COMPLETED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_CLOSE_SYSTEM_DIALOGS = "android.intent.action.CLOSE_SYSTEM_DIALOGS";

@Deprecated
@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_INSTALL = "android.intent.action.PACKAGE_INSTALL";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_ADDED = "android.intent.action.PACKAGE_ADDED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_REPLACED = "android.intent.action.PACKAGE_REPLACED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MY_PACKAGE_REPLACED = "android.intent.action.MY_PACKAGE_REPLACED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_REMOVED = "android.intent.action.PACKAGE_REMOVED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_FULLY_REMOVED = "android.intent.action.PACKAGE_FULLY_REMOVED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_CHANGED = "android.intent.action.PACKAGE_CHANGED";

@SystemApi
@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_QUERY_PACKAGE_RESTART = "android.intent.action.QUERY_PACKAGE_RESTART";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_RESTARTED = "android.intent.action.PACKAGE_RESTARTED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_DATA_CLEARED = "android.intent.action.PACKAGE_DATA_CLEARED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGES_SUSPENDED = "android.intent.action.PACKAGES_SUSPENDED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGES_UNSUSPENDED = "android.intent.action.PACKAGES_UNSUSPENDED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_UID_REMOVED = "android.intent.action.UID_REMOVED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_FIRST_LAUNCH = "android.intent.action.PACKAGE_FIRST_LAUNCH";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_NEEDS_VERIFICATION = "android.intent.action.PACKAGE_NEEDS_VERIFICATION";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PACKAGE_VERIFIED = "android.intent.action.PACKAGE_VERIFIED";

@SystemApi
@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_INTENT_FILTER_NEEDS_VERIFICATION = "android.intent.action.INTENT_FILTER_NEEDS_VERIFICATION";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_EXTERNAL_APPLICATIONS_AVAILABLE = "android.intent.action.EXTERNAL_APPLICATIONS_AVAILABLE";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_EXTERNAL_APPLICATIONS_UNAVAILABLE = "android.intent.action.EXTERNAL_APPLICATIONS_UNAVAILABLE";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PREFERRED_ACTIVITY_CHANGED = "android.intent.action.ACTION_PREFERRED_ACTIVITY_CHANGED";

@Deprecated @SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_WALLPAPER_CHANGED = "android.intent.action.WALLPAPER_CHANGED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_CONFIGURATION_CHANGED = "android.intent.action.CONFIGURATION_CHANGED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_LOCALE_CHANGED = "android.intent.action.LOCALE_CHANGED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_BATTERY_CHANGED = "android.intent.action.BATTERY_CHANGED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_BATTERY_LOW = "android.intent.action.BATTERY_LOW";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_BATTERY_OKAY = "android.intent.action.BATTERY_OKAY";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_POWER_CONNECTED = "android.intent.action.ACTION_POWER_CONNECTED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_POWER_DISCONNECTED = "android.intent.action.ACTION_POWER_DISCONNECTED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_SHUTDOWN = "android.intent.action.ACTION_SHUTDOWN";
public static final String ACTION_REQUEST_SHUTDOWN = "com.android.internal.intent.action.REQUEST_SHUTDOWN";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
@Deprecated
public static final String ACTION_DEVICE_STORAGE_LOW = "android.intent.action.DEVICE_STORAGE_LOW";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
@Deprecated
public static final String ACTION_DEVICE_STORAGE_OK = "android.intent.action.DEVICE_STORAGE_OK";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
@Deprecated
public static final String ACTION_DEVICE_STORAGE_FULL = "android.intent.action.DEVICE_STORAGE_FULL";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
@Deprecated
public static final String ACTION_DEVICE_STORAGE_NOT_FULL = "android.intent.action.DEVICE_STORAGE_NOT_FULL";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MANAGE_PACKAGE_STORAGE = "android.intent.action.MANAGE_PACKAGE_STORAGE";

@Deprecated
@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_UMS_CONNECTED = "android.intent.action.UMS_CONNECTED";

@Deprecated
@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_UMS_DISCONNECTED = "android.intent.action.UMS_DISCONNECTED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_REMOVED = "android.intent.action.MEDIA_REMOVED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_UNMOUNTED = "android.intent.action.MEDIA_UNMOUNTED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_CHECKING = "android.intent.action.MEDIA_CHECKING";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_NOFS = "android.intent.action.MEDIA_NOFS";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_MOUNTED = "android.intent.action.MEDIA_MOUNTED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_SHARED = "android.intent.action.MEDIA_SHARED";

public static final String ACTION_MEDIA_UNSHARED = "android.intent.action.MEDIA_UNSHARED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_BAD_REMOVAL = "android.intent.action.MEDIA_BAD_REMOVAL";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_UNMOUNTABLE = "android.intent.action.MEDIA_UNMOUNTABLE";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_EJECT = "android.intent.action.MEDIA_EJECT";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_SCANNER_STARTED = "android.intent.action.MEDIA_SCANNER_STARTED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_SCANNER_FINISHED = "android.intent.action.MEDIA_SCANNER_FINISHED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_SCANNER_SCAN_FILE = "android.intent.action.MEDIA_SCANNER_SCAN_FILE";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MEDIA_BUTTON = "android.intent.action.MEDIA_BUTTON";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_CAMERA_BUTTON = "android.intent.action.CAMERA_BUTTON";

// *** NOTE: @todo(*) The following really should go into a more domain-specific
// location; they are not general-purpose actions.

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_GTALK_SERVICE_CONNECTED = "android.intent.action.GTALK_CONNECTED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_GTALK_SERVICE_DISCONNECTED = "android.intent.action.GTALK_DISCONNECTED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_INPUT_METHOD_CHANGED = "android.intent.action.INPUT_METHOD_CHANGED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_AIRPLANE_MODE_CHANGED = "android.intent.action.AIRPLANE_MODE";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_PROVIDER_CHANGED = "android.intent.action.PROVIDER_CHANGED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_HEADSET_PLUG = android.media.AudioManager.ACTION_HEADSET_PLUG;

//@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_ADVANCED_SETTINGS_CHANGED = "android.intent.action.ADVANCED_SETTINGS";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_APPLICATION_RESTRICTIONS_CHANGED = "android.intent.action.APPLICATION_RESTRICTIONS_CHANGED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_NEW_OUTGOING_CALL = "android.intent.action.NEW_OUTGOING_CALL";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_REBOOT = "android.intent.action.REBOOT";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_DOCK_EVENT = "android.intent.action.DOCK_EVENT";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_IDLE_MAINTENANCE_START = "android.intent.action.ACTION_IDLE_MAINTENANCE_START";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_IDLE_MAINTENANCE_END = "android.intent.action.ACTION_IDLE_MAINTENANCE_END";

public static final String ACTION_REMOTE_INTENT = "com.google.android.c2dm.intent.RECEIVE";

@SystemApi
public static final String ACTION_PRE_BOOT_COMPLETED = "android.intent.action.PRE_BOOT_COMPLETED";

public static final String ACTION_GET_RESTRICTION_ENTRIES = "android.intent.action.GET_RESTRICTION_ENTRIES";

public static final String ACTION_USER_INITIALIZE = "android.intent.action.USER_INITIALIZE";

public static final String ACTION_USER_FOREGROUND = "android.intent.action.USER_FOREGROUND";

public static final String ACTION_USER_BACKGROUND = "android.intent.action.USER_BACKGROUND";

public static final String ACTION_USER_ADDED = "android.intent.action.USER_ADDED";

public static final String ACTION_USER_STARTED = "android.intent.action.USER_STARTED";

public static final String ACTION_USER_STARTING = "android.intent.action.USER_STARTING";

public static final String ACTION_USER_STOPPING = "android.intent.action.USER_STOPPING";

public static final String ACTION_USER_STOPPED = "android.intent.action.USER_STOPPED";

@SystemApi
public static final String ACTION_USER_REMOVED = "android.intent.action.USER_REMOVED";

public static final String ACTION_USER_SWITCHED = "android.intent.action.USER_SWITCHED";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_USER_UNLOCKED = "android.intent.action.USER_UNLOCKED";

public static final String ACTION_USER_INFO_CHANGED = "android.intent.action.USER_INFO_CHANGED";

public static final String ACTION_MANAGED_PROFILE_ADDED = "android.intent.action.MANAGED_PROFILE_ADDED";

public static final String ACTION_MANAGED_PROFILE_REMOVED = "android.intent.action.MANAGED_PROFILE_REMOVED";

public static final String ACTION_MANAGED_PROFILE_UNLOCKED = "android.intent.action.MANAGED_PROFILE_UNLOCKED";

public static final String ACTION_MANAGED_PROFILE_AVAILABLE = "android.intent.action.MANAGED_PROFILE_AVAILABLE";

public static final String ACTION_MANAGED_PROFILE_UNAVAILABLE = "android.intent.action.MANAGED_PROFILE_UNAVAILABLE";

public static final String ACTION_DEVICE_LOCKED_CHANGED = "android.intent.action.DEVICE_LOCKED_CHANGED";

public static final String ACTION_QUICK_CLOCK = "android.intent.action.QUICK_CLOCK";

public static final String ACTION_SHOW_BRIGHTNESS_DIALOG = "com.android.intent.action.SHOW_BRIGHTNESS_DIALOG";

@SystemApi
public static final String ACTION_GLOBAL_BUTTON = "android.intent.action.GLOBAL_BUTTON";

public static final String ACTION_MEDIA_RESOURCE_GRANTED = "android.intent.action.MEDIA_RESOURCE_GRANTED";

public static final String ACTION_OVERLAY_CHANGED = "android.intent.action.OVERLAY_CHANGED";

@SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
public static final String ACTION_OPEN_DOCUMENT = "android.intent.action.OPEN_DOCUMENT";

@SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
public static final String ACTION_CREATE_DOCUMENT = "android.intent.action.CREATE_DOCUMENT";

@SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
public static final String ACTION_OPEN_DOCUMENT_TREE = "android.intent.action.OPEN_DOCUMENT_TREE";

@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_DYNAMIC_SENSOR_CHANGED = "android.intent.action.DYNAMIC_SENSOR_CHANGED";

@Deprecated
@SystemApi
public static final String ACTION_MASTER_CLEAR = "android.intent.action.MASTER_CLEAR";

@SystemApi
@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_MASTER_CLEAR_NOTIFICATION = "android.intent.action.MASTER_CLEAR_NOTIFICATION";

@Deprecated
public static final String EXTRA_FORCE_MASTER_CLEAR = "android.intent.extra.FORCE_MASTER_CLEAR";

@SystemApi
@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_FACTORY_RESET = "android.intent.action.FACTORY_RESET";

@SystemApi
public static final String EXTRA_FORCE_FACTORY_RESET = "android.intent.extra.FORCE_FACTORY_RESET";

public static final String ACTION_SETTING_RESTORED = "android.os.action.SETTING_RESTORED";

public static final String EXTRA_SETTING_NAME = "setting_name";
public static final String EXTRA_SETTING_PREVIOUS_VALUE = "previous_value";
public static final String EXTRA_SETTING_NEW_VALUE = "new_value";
public static final String EXTRA_SETTING_RESTORED_FROM_SDK_INT = "restored_from_sdk_int";

@SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
public static final String ACTION_PROCESS_TEXT = "android.intent.action.PROCESS_TEXT";

@SystemApi
@SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_SIM_STATE_CHANGED = "android.intent.action.SIM_STATE_CHANGED";

@Deprecated
@SystemApi
@SdkConstant(SdkConstant.SdkConstantType.BROADCAST_INTENT_ACTION)
public static final String ACTION_SERVICE_STATE = "android.intent.action.SERVICE_STATE";


시스템 이벤트는 앱에서 발생시킬 수 없음


문서를 보면 'This is a protected intent that can only be sent by the system.'라는 메시지가 많이 나온다. 즉 해당 인텐트는 시스템에서만 발생시킬 수 있고 앱에서 발생시킬 수 없다.
아래는 sendBroadcast(new Intent(Intent.ACTION_BOOT_COMPLETED)) 를 실행한 결과 예외 스택이다.

12345678910
2018-11-05 17:41:44.087 8728-8728/com.tistory.gpark.nextstep E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.tistory.gpark.nextstep, PID: 8728
    java.lang.SecurityException: Permission Denial: not allowed to send broadcast android.intent.action.BOOT_COMPLETED from pid=8728, uid=10079
        at android.os.Parcel.readException(Parcel.java:2004)
        at android.os.Parcel.readException(Parcel.java:1950)
        at android.app.IActivityManager$Stub$Proxy.broadcastIntent(IActivityManager.java:4491)
        at android.app.ContextImpl.sendBroadcast(ContextImpl.java:970)
        at android.content.ContextWrapper.sendBroadcast(ContextWrapper.java:437)

자주 쓰이는 시스템 이벤트

캘린더 앱의 경우 처리해야 하는 이벤트로는 ACTION_TIMEZONE_CHANGED, ACTION_LOCALE_CHANGED 액션이 있다. 많은 앱에서 처리하는 이벤트로는 ACTION_BOOT_COMPLETED 액션이 있다.

시스템 이벤트가 아닌 것도 정의되어 있음

Intent 클래스에 정의된 Broadcast Actino이 모두 시스템 이벤트인 것은 아니다. ACTION_MEDIA_SCANNER_SCAN_FILE 액션 같은 것은 앱에서 발생시키라고 있는 것이다. 이미지를 SD 카드에 저장했는데 Media Scanning이 안 돼서 곧바로 화면에 가져올 수 없는 경우가 있다. 이때 앱에서 사용하는 방식이 ACTION_MEDIA_SCANNER_SCAN_FILE 액션을 Broadcast해서 Media Sacanner가 동작하게 하는 것이다.

Dynamically register Broadcast Receiver


Context의 registerReceiver() 메서드로 BroadcastReceiver를 동적으로 등록한다. 이는 앱 프로세스가 떠 있고 BroadcastReceiver를 등록한 활성화된 컴포넌트가 있을 때만 동작하는 것이다. BroadcastReceiver는 unregisterReceiver() 메서드에서 해제한다. 일반적으로 Activity에서는 Foregournd Life Time인 onResume()/onPause()에서 registerReceiver()/unregisterReceiver()를 호출한다.

example

볼륨 변경 시 Broadcast 처리
1234567891011121314151617181920212223242526272829303132
public static final String VOLUME_CHANGED_ACTION =
        "android.media.VOLUME_CHANGED_ACTION";
public static final String EXTRA_VOLUME_STREAM_VALUE =
        "android.media.EXTRA_VOLUME_STREAM_VALUE";

private BroadcastReceiver mReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
        if (VOLUME_CHANGED_ACTION.equals(intent.getAction())) {
            int value = intent.getIntExtra(EXTRA_VOLUME_STREAM_VALUE, -1);
            if (value > -1) {
                mVolumeSeekBar.setProgress(value);
            }
        }
    }
};

@Override
protected void onResume() {
    super.onResume();
    IntentFilter intentFilter = new IntentFilter();
    intentFilter.addAction(VOLUME_CHANGED_ACTION);
    registerReceiver(mReceiver, intentFilter);
}

@Override
protected void onPause() {
    unregisterReceiver(mReceiver);
    super.onPause();
}

여기서 VOLUME_CHANGED_ACTION 액션은 안드로이드 API 문서에 없는 내용이다. 코드상으로는 AudioManager 클래스에 상수로 있는데 @hide 애너테이션으로 숨겨져 있다. EXTRA_VOLUME_STREAM_VALUE도 마찬가지로 숨겨져 있다. 이 샘플과 같이 액션명을 알 수 있다면 처리할 수 있는 케이스가 많다.

바탕화면에서 숏컷 설치


바탕화면에 숏컷(shortcut)을 설치하는 것도 Broadcast를 사용한다. com.android.launcher.action.INSTALL_SHORTCUT도 API 문서에 없는 액션이다.

example

숏컷 설치 Broadcast
123456789101112131415161718192021
Intent shortcutIntent = new Intent("android.intent.action.MAIN", null);

PackageManager pm = context.getPackageManager();
Intent launchIntent = pm.getLaunchIntentForPackage(context.getPackageName());
String className= launchIntent.getComponent().getClassName();

shortcutIntent.setClassName(context, className);
shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
                       | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
shortcutIntent.addCategory(Intent.CATEGORY_LAUNCHER);

Intent intent = new Intent();
intent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent);
intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, context.getString(R.string.app_name));
intent.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE,
               Intent.ShortcutIconResoucre.fromContext(context, R.drawable.icon));
intent.setAction("com.android.launcher.action.INSTALL_SHORTCUT");
intent.putExtra("duplicate", false);
context.sendBoradcast(intent);

여기에 필요한 퍼미션은 안드로이드 개발 문서에 나와있다.

1234
<uses-permission android:name = 
                 "com.android.launcher.permission.INSTALL_SHORTCUT" />


FLAG_RECEIVER_REGISTERED_ONLY 상수


Intent에는 동적으로 등록된 BroadcastReceiver만 이벤트를 받게 하는 옵션도 있다. Intent의 setFlags() 메서드에 파라미터로 Intent.FLAG_RECEIVER_REGISTERED_ONLY를 전달하면 정적으로 등록된 BroadcastReceiver는 이벤트를 받지 못한다.

Ordered Broadcast


sendOrderedBroadcast(Intent intent, String receiverPermission) 메서드는 등록된 BroadcastReceiver 가운데 priority 값이 높은 순으로 전달한다.

  • priority 넣는 방법
    • AndroidManifest.xml에서 intent-filter에 android:priority의 값
    • IntentFilter에 setPriority(int priority) 메서드 이용

BroadcastReceiver는 프로세스 간 통신이 필요하므로 가벼운 작업은 아니다. Ordered Broadcast는 여러 BroadcastReceiver 간에 결과를 넘겨가면서 계속 진행하는 용도보다는, 결과를 넘기다가 적정 시점이 되면 나머지 BroadcastReceiver를 skip하는 용도로 적합하다.

example

그룹 앱이 있을 경우(ex: 오피스) 파일 읽기를 시도한다. 문서 앱에서 읽을 수 있는 파일이 아니면 스프레드시트 앱에서 읽기를 시도하고, 여기서도 안 되면 마지막으로 프리젠테이션 앱으로 읽기를 시도한다. 이때 사용 가능한 방식이 Ordered Broadcast를 발생시키는 것이다. 각각의 앱에서 우선순위가 다른 BroadcastReceiver가 있으면 된다. 우선순위가 높은 BroadcastReceiver에서 정상적으로 처리될 때 abortBroadcast()를 호출하면 된다. 이때 다음 BroadcastReceiver에는 Broadcast가 전달되지 않는다.

Sticky Broadcast


sendStickyBroadcast()는 Intent를 시스템에 등록해놓고, 해당 Intent를 받을 수 있는 BroadcastReceiver가 새로 등록되면 이 시점에 BroadcastReceiver의 onReceive()가 호출된다. 즉, 이벤트를 먼저 발생시키더라도 이벤트 상태를 알고자 하는 BroadcastReceiver가 등록되면 이벤트를 받는다.

시스템에서 보내는 Sticky Broadcast


시스템에서 보내는 Sticky Broadcast는 Intent API 문서에서 'sticky broadcast'로 검색하면 확인할 수 있다.

Sticky Broadcast
  • ACTION_BATTERY_CHANGED : 배터리 상태
  • ACTION_DEVICE_STORAGE_LOW : 저장소 부족 여부
  • ACTION_DOCK_EVENT : 도킹 상태

example

배터리 레벨 변경 Sticky Broadcast 처리
12345678910111213141516171819202122232425262728
@Override
protected void onResume() {
    super.onResume();
    IntentFilter filter = new IntentFilter();
    filter.addAction(Intent.ACTION_BATTERY_CHANGED);
    registerReceiver(mReceiver, filter);
}

private BroadcastReceiver mReceiver = new BoradcastReceiver() {
    
    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (Intent.ACTION_BATTERY_CHANGED.equals(action)) {
            Bundle bundle = intent.getExtras();
            int level = bundle.getint(BatteryManager.EXTRA_LEVEL);
            ...
        }
    }
}

@Override
protected void onPause() {
    unregisterReceiver(mReceiver);
    super.onPause();
}


앱에서는 Sticky Broadcast를 권장하지 않음


앱에서는 sendStickyBroadcast() 메서드 호출이 권장되지 않는다. 롤리팝에서는 이 메서드의 지원이 중단하기도 했다(deprecated). 이벤트를 보내는 용도로 Sticky Broadcast를 쓰지 말고, BraodcastReceiver에서 Sticky Broadcast를 받아서 처리하는 데만 사용해야 한다.

Stciky Broadcast의 보안 문제

Sticky Broadcast는 시스템 메모리에 정보가 계속 남아 있다. 그래서 어디선가 정보를 알고 싶을 때 언제든지 빼낼 수가 있기 때문에 보안 문제를 초래할 수 있다.

example

여러 앱 간에 SSO(single sign-on) 기능을 구현한다고 하자. 한 앱에서 로그인하면 같은 아이디로 다른 앱에서도 자동 로그인되고, 명시적으로 로그아웃하면 다른 앱에서도 자동 로그아웃 되는게 SSO의 기본 기능이다.

question

SSO를 구현하기 위해 Intent에 로그인 여부와 로그인 아이디 등을 Sticky Broadcast로 전달하면 어떨까?

answer

onResume()에서 registerReceiver()를 실행한다면, 앱이 Foreground로 올 때마다 최신 정보를 알 수 있고 그에 맞게 처리할 수 있다. 하지만 이 정보는 다른 앱에서도 읽을 수 있고, Intent 정보를 다른 것으로 바꿔치기 할 수도 있다. 이러한 문제 때문에 앱에서는 sendStickyBroadcast()를 쓰지 않는게 좋다.

LocalBroadcastManager 클래스


Context의 sendBroadcast() 메서드는 Binder IPC를 통해 ActivityManagerService를 거쳐야 하므로 속도에서 이점이 크지 않다. 또한 Intent 액션을 안다면 원치 않는 고샤에서도 이벤트를 받아서 예기치 않는 일을 할 가능성도 있다.
(sendBroadcast()에서는 setPackage() 메서드를 사용해서 원하는 패키지만 Broadcast를 전달할 수 있지만 이 방식은 ICS부터 동작한다)
프로세스 간에 Braodcast를 보낼 필요가 없다면 support-v4에 포함된 LocalBroadcastManager의 사용을 고려해야 한다.
LocalBroadcastManager는 로컬 프로세스에서만 이벤트를 주고받을 수 있다. ActivityManagerService를 거치지 않고 Singleton인 LocalBroadcastdManager에서 registerReceiver()와 sendBroadcast()를 실행한다.

12345
LocalBroadcastManager.getInstance(this).sendBroadcast(CalendarIntent.CHANGE_TIME);
...
LocalBroadcastManager.getInstance(this).registerReceiver(...);

LocalBroadcastManager 장점
  • Broadcast하는 데이터가 다른 앱에서 catch되지 않아서 안전하다.
  • Global Broadcast보다 속도가 빠르다.

LocalBroadcastManager 내부에서 Handler 사용


LocalBroadcastManager에 등록된 BroadcastReceiver의 sendBroadcast() 메서드는 BroadcastReceiver를 바로 실행하지 않는다. Handler에 Message를 보내서 Main Thread에서 가능한 시점에 처리한다. 따라서 Main Looper의 MessageQueue에 쌓여 있는게 많다면 처리가 늦어질 수 있다.

sendBroadcastSync() 메서드


인증 에러 같은 것을 MessageQueue에서 처리하면 문제가 될 수 있다. BroadcastReceiver에서 바로 처리해야 하는데, 그렇지 않고 다른 작업을 먼저한다면 타이밍상 엉뚱한 결과를 만들어 내는 경우가 생긴다. 이때 쓰는 것이 sendBroadcastSync() 메서드이다. 등록된 BroadcastReceiver는 모두 그 순간에 처리하는데, 이때 sendBroadcastSync()와 onReceive()는 동일한 Thread에서 실행된다.

앱 위젯에는 Local Broadcast가 전달되지 않음


LocalBroadcastManager에서 sendBroadcast()를 호출할 때 BroadcastReceiver의 한 종류인 앱 위젯에는 이벤트가 전달되지 않는다. 앱 위젯은 홈 스크린에서 설치되어 별도 프로세스에 있으므로, Global Broadcast를 사용해서 이벤트를 전달해야만 한다. 다만 앱 위젯의 onReceive() 실행 위치는 다시 앱의 프로세스이다.

App Widget


앱 위젯(App Widget)은 다른 애플리케이션에서 내장되어서(embeded), 주기적으로 업데이트하는 작은 애플리케이션(miniature application views)이다.

App Widget의 특성


설치되는 프로세스와 실행되는 프로세스가 다름


설치되는 위치(launch process)와 실행되는 위치(app process)가 다르다. AppWidgetService(앱 위젯 목록 유지, 이벤트 발생)가 실행되는 system_server까지 포함하면 관련 프로세스는 모두 3개이다.

Broadcast를 통해 App Widget 변경


시스템(AppWidgetService)에서 sendBroadcast()를 호출하면 BroadcastReceiver의 onReceive() 메서드에서 앱 위젯에 작업을 하는 방식을 주로 쓴다.

example

AndroidManifest.xml
123456789
<receiver android:name=".appwidget.ExampleAppWidgetProvider">
    <meta-data android:name="android.appwidget.provider"
               android:resource="@xml/appwidget_provider" />
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
    </intent-filter>
</receiver>

res/xml/appwidget_provider
12345678910
<appwidget-provider xmlns:android="http://schemas.androidcom/apk/res/android"
                    android:minWidth="60dp"
                    android:minHeight="30dp"
                    android:updatePeriodMillis="86400000"
                    android:initialLayout="@layout/appwidget_provider"
                    android:configure="com.example.android.apis.appwidget.ExampleAppWidgetConfigure"
                    android::resizeMode="horizontal">
</appwidget-provider>


AppWidgetProvider 클래스


App Widget은 BroadcastReceiver로도 만들 수 있지만 대체로 AppWidgetProvider를 상속해서 만든다. AppWidgetProvider는 내부적으로 onReceive()에서 Intent 액션으로 구분한 후 onUpdate(), onDeleted(), onEnabled(), onDisabled() 메서드로 Intent extra 값을 전달한다.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
public class AppWidgetProvider extends BroadcastReceiver {
    /**
     * Constructor to initialize AppWidgetProvider.
     */
    public AppWidgetProvider() {
    }

    /**
     * Implements {@link BroadcastReceiver#onReceive} to dispatch calls to the various
     * other methods on AppWidgetProvider.  
     *
     * @param context The Context in which the receiver is running.
     * @param intent The Intent being received.
     */
    // BEGIN_INCLUDE(onReceive)
    public void onReceive(Context context, Intent intent) {
        // Protect against rogue update broadcasts (not really a security issue,
        // just filter bad broacasts out so subclasses are less likely to crash).
        String action = intent.getAction();
        if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(action)) {
            Bundle extras = intent.getExtras();
            if (extras != null) {
                int[] appWidgetIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS);
                if (appWidgetIds != null && appWidgetIds.length > 0) {
                    this.onUpdate(context, AppWidgetManager.getInstance(context), appWidgetIds);
                }
            }
        } else if (AppWidgetManager.ACTION_APPWIDGET_DELETED.equals(action)) {
            Bundle extras = intent.getExtras();
            if (extras != null && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID)) {
                final int appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID);
                this.onDeleted(context, new int[] { appWidgetId });
            }
        } else if (AppWidgetManager.ACTION_APPWIDGET_OPTIONS_CHANGED.equals(action)) {
            Bundle extras = intent.getExtras();
            if (extras != null && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID)
                    && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_OPTIONS)) {
                int appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID);
                Bundle widgetExtras = extras.getBundle(AppWidgetManager.EXTRA_APPWIDGET_OPTIONS);
                this.onAppWidgetOptionsChanged(context, AppWidgetManager.getInstance(context),
                        appWidgetId, widgetExtras);
            }
        } else if (AppWidgetManager.ACTION_APPWIDGET_ENABLED.equals(action)) {
            this.onEnabled(context);
        } else if (AppWidgetManager.ACTION_APPWIDGET_DISABLED.equals(action)) {
            this.onDisabled(context);
        } else if (AppWidgetManager.ACTION_APPWIDGET_RESTORED.equals(action)) {
            Bundle extras = intent.getExtras();
            if (extras != null) {
                int[] oldIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_OLD_IDS);
                int[] newIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS);
                if (oldIds != null && oldIds.length > 0) {
                    this.onRestored(context, oldIds, newIds);
                    this.onUpdate(context, AppWidgetManager.getInstance(context), newIds);
                }
            }
        }
    }
    // END_INCLUDE(onReceive)

    /**
     * Called in response to the {@link AppWidgetManager#ACTION_APPWIDGET_UPDATE} and
     * {@link AppWidgetManager#ACTION_APPWIDGET_RESTORED} broadcasts when this AppWidget
     * provider is being asked to provide {@link android.widget.RemoteViews RemoteViews}
     * for a set of AppWidgets.  Override this method to implement your own AppWidget functionality.
     *
     * {@more}
     * 
     * @param context   The {@link android.content.Context Context} in which this receiver is
     *                  running.
     * @param appWidgetManager A {@link AppWidgetManager} object you can call {@link
     *                  AppWidgetManager#updateAppWidget} on.
     * @param appWidgetIds The appWidgetIds for which an update is needed.  Note that this
     *                  may be all of the AppWidget instances for this provider, or just
     *                  a subset of them.
     *
     * @see AppWidgetManager#ACTION_APPWIDGET_UPDATE
     */
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
    }

    /**
     * Called in response to the {@link AppWidgetManager#ACTION_APPWIDGET_OPTIONS_CHANGED}
     * broadcast when this widget has been layed out at a new size.
     *
     * {@more}
     *
     * @param context   The {@link android.content.Context Context} in which this receiver is
     *                  running.
     * @param appWidgetManager A {@link AppWidgetManager} object you can call {@link
     *                  AppWidgetManager#updateAppWidget} on.
     * @param appWidgetId The appWidgetId of the widget whose size changed.
     * @param newOptions The appWidgetId of the widget whose size changed.
     *
     * @see AppWidgetManager#ACTION_APPWIDGET_OPTIONS_CHANGED
     */
    public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager,
            int appWidgetId, Bundle newOptions) {
    }

    /**
     * Called in response to the {@link AppWidgetManager#ACTION_APPWIDGET_DELETED} broadcast when
     * one or more AppWidget instances have been deleted.  Override this method to implement
     * your own AppWidget functionality.
     *
     * {@more}
     * 
     * @param context   The {@link android.content.Context Context} in which this receiver is
     *                  running.
     * @param appWidgetIds The appWidgetIds that have been deleted from their host.
     *
     * @see AppWidgetManager#ACTION_APPWIDGET_DELETED
     */
    public void onDeleted(Context context, int[] appWidgetIds) {
    }

    /**
     * Called in response to the {@link AppWidgetManager#ACTION_APPWIDGET_ENABLED} broadcast when
     * the a AppWidget for this provider is instantiated.  Override this method to implement your
     * own AppWidget functionality.
     *
     * {@more}
     * When the last AppWidget for this provider is deleted,
     * {@link AppWidgetManager#ACTION_APPWIDGET_DISABLED} is sent by the AppWidget manager, and
     * {@link #onDisabled} is called.  If after that, an AppWidget for this provider is created
     * again, onEnabled() will be called again.
     *
     * @param context   The {@link android.content.Context Context} in which this receiver is
     *                  running.
     *
     * @see AppWidgetManager#ACTION_APPWIDGET_ENABLED
     */
    public void onEnabled(Context context) {
    }

    /**
     * Called in response to the {@link AppWidgetManager#ACTION_APPWIDGET_DISABLED} broadcast, which
     * is sent when the last AppWidget instance for this provider is deleted.  Override this method
     * to implement your own AppWidget functionality.
     *
     * {@more}
     * 
     * @param context   The {@link android.content.Context Context} in which this receiver is
     *                  running.
     *
     * @see AppWidgetManager#ACTION_APPWIDGET_DISABLED
     */
    public void onDisabled(Context context) {
    }

    /**
     * Called in response to the {@link AppWidgetManager#ACTION_APPWIDGET_RESTORED} broadcast
     * when instances of this AppWidget provider have been restored from backup.  If your
     * provider maintains any persistent data about its widget instances, override this method
     * to remap the old AppWidgetIds to the new values and update any other app state that may
     * be relevant.
     *
     * <p>This callback will be followed immediately by a call to {@link #onUpdate} so your
     * provider can immediately generate new RemoteViews suitable for its newly-restored set
     * of instances.
     *
     * {@more}
     *
     * @param context
     * @param oldWidgetIds
     * @param newWidgetIds
     */
    public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) {
    }
}

appWidgetId는 앱 위젯을 홈 스크린에 꺼낼 때마다 새로 받는 인스턴스 id 값이다. 동일한 앱 위젯이 홈 스크린에 여러 개 깔리더라도 인스턴스 id 값은 모두 다르다. 즉, 앱 위젯의 종류별로 있는 값이 아니라 전역적인 값이다.

  • App Widget Action 종류
    • ACTION_APPWIDGET_UPDATE : 부팅 시, 최초 설치 시, 업데이트 간격(update interval) 경과 시에 호출. 앱 위젯에서 가장 중요한 Intent 액션
    • ACTION_APPWIDGET_DELETED : 인스턴스가 삭제될 때 호출
    • ACTION_APPWIDGET_OPTIONS_CHANGED : 앱 위젯이 새로운 사이즈로 레이아웃될 떄 호출(젤리빈부터)
    • ACTION_APPWIDGET_ENABLED : 최초 설치 시에 호출
    • ACTION_APPWIDGET_DISABLED : 마지막 인스턴스가 삭제될 때 호출

ACTION_APPWIDGET_DELETED, ACTION_APPWIDGET_OPTIONS_CHANGED, ACTION_APPWIDGET_ENABLED, ACTION_APPWIDGET_DISABLED는 시스템에서만 보낼 수 있다(protected intent). 앱에서는 ACTION_APPWIDGET_UPDATE 액션만 보낼 수 있다.

RemoteViews 클래스


RemoteViews는 다른 프로세스에 있는 뷰 계층을 나타내는 클래스이다. 알림(Notification)이나 앱 위젯에서는 앱에서 만든 레이아웃을 다른 프로세스에 보여줄 때 RemoteViews가 사용된다. RemoteViews는 클래스명 때문에 일종의 ViewGroup으로 생각할 수 있다. 하지만 android.widget 패키지 안에 있을 뿐 View나 ViewGroup을 상속한 것이 아니다. RemoteViews는 Parcelable과 layoutInflater.Filter 인터페이스를 구현한 클래스일 뿐이다.

question

Toast도 내부적으로 Notification과 동일하게 system_server 프로세스의 NotificationManagerService를 사용한다. 그런데 Toast는 왜 커스텀 레이아웃으로 만들 때 RemoteViews를 쓰지 않을까?

answer

Toast는 바인더 콜백을 전달하고 바인더 콜백에서 띄우는 것이기 때문에 RemoteViews가 필요하지 않다.

RemoteViews에 쓸 수 있는 뷰 클래스


RemoteViews의 레이아웃에 쓸 수 있는 뷰 클래스는 한정되어 있는데, LayoutInflater.Filter 인터페이스의 onLoadClass() 메서드가 뷰 클래스를 제한한다. LayoutInflater.Filter 구현체인 RemoteViews에서 booleanonLoadClass(Class clazz) 메서드를 보면 클래스 선언에 @RemoteView 애너테이션이 있는 것만 true를 리턴한다.

  • @RemoteView 애너테이션이 있는 뷰 클래스

    • FrameLayout, LinearLayout, RelativeLayout, GridLayout
    • AnalogClock, Button, Chronometer, ImageButton, ImageView, ProgressBar, TextView, ViewFlipper
    • ListView, GridView, StackView, AdapterViewFlipper(허니콤부터 지원)
  • 빈번하게 발생하는 시행착오

    • 애너테이션은 상속된 클래스에는 적용되지 않기 때문에 위 목록에 있는 클래스의 하위 클래스도 RemoteViews에는 쓸 수 없다.
    • Custom View도 쓸 수 없다. 클래스 선언에 @RemoteView를 추가하면 될 것 같지만, 설치되는 프로세스인 launcher에서 이 Custom View를 찾을 방법이 없기 때문이다.
    • 최상위 클래스인 android.view.View는 쓸 수 없다. 레이아웃에 내용을 구분하기 위한 단순 라인 구분자(line separator)를 만들 때는 View에 배경색(background color)을 넣으면 됐지만, RemoteViews에서는 TextView처럼 지원되는 뷰 클래스로 대체해야 한다. 이 경우 레이아웃에서 Lint 경고를 볼 수 있는데 앱 위젯에서는 다른 방법이 없기 때문에 이런 경고는 무시해도 된다.

뷰 클래스에서 사용 가능한 메서드


RemoteViews의 메서드 가운데서 두 번째 파라미터에 methodName 문자열이 전달되는 것이 있는데, 리플렉션을 통해 새 번째 파라미터 값을 두 번째 파라미터인 methodName 이름의 메서드에 파라미터로 전달하게 된다.

[참고] Java Reflection

Java Reflection 개념 및 사용법

Update App Widget


RemoteViews를 써서 앱 위젯을 업데이트하는 코드
123456
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.appwidget_provider);
views.setTextViewText(R.id.appwidget_text, text);
...
appWidgetManager.updateAppWidget(appWidgetId, views);

question

그렇다면 앱 위젯을 갱신하는 이 루틴이 반드시 BroadcastReceiver에서 실행되어야 할까?

answer

그렇지는 않다. AppWidgetManager.getInstance(Context context)를 호출하면 Context가 전달되는 어디서든 AppWidgetManager를 얻을 수 있기 때문에, Activity나 Service에서도 동일하게 루틴을 실행할 수 있다. 게다가 Background Thread에서 루틴을 실행해도 문제가 없다. 이것이 Service의 Background Thread에서 앱 위젯을 업데이트해도 되는 이유이다.

updateAppWidget() 호출 스택


12345678
AppWidgetManager.updateAppWidget
    AppWidgetService.updateAppWidgetIds
        AppWidgetServiceImpl.updateAppWidgetIds
            AppWidgetServiceImpl.updateAppWidgetInstanceLocked
                IAppWidgetHost.updateAppWidget[callback in AppWidgetHost]
                    AppWidgetHostView.updateAppWidget[via Handler]

Parcelable인 RemoteViews는 계속해서 파라미터에 전달되고, AppWidgetHostView는 Handler에 작업을 전달해서 RemoteViews의 액션 목록을 한꺼번에 처리한다.

Keeping Collection Data Fresh

출처 : Build an App Widget  |  Android Developers


유의할 점

Main Thread 점유


onReceive() 메서드는 당연히 Main Thread에서 실행된다. 따라서 여기서도 실행 시간에 주의해야 한다. Foreground에서 앱을 사용하는 중에, 앱 위젯을 업데이트하기 위해 onReceive()가 실행된다면 UI 동작이 버벅거리는 원인이 될 수 있다.

question

onReceive() 메서드 내에서 앱 위젯을 업데이트하기 위해 네트워크 통신이 필요하거나 DB에서 가져올 데이터가 많아 처리 시간이 많이 예상되는 경우에는 어떻게 할 것인가?

answer

이런 경우에도 앱 위젯 갱신 작업을 Service로 넘기고 Service에서는 Background Thread에서 처리해야 한다.

question

onReceive 메서드에서 AsyncTask를 실행해서 앱 위젯을 업데이트하는 경우가 있는데, BroadcastReceiver는 onReceive() 메서드가 return되면 프로세스가 제거될 수 있기 때문에 실행을 보장할 수 없다. 앱의 Activity가 Foreground에 있다면 프로세스 우선 순위가 높아서 거의 문제가 되지 않는다. 그런데 앱 위젯에 설정한 업데이트 간격(update interval)이 되었거나 특정 Broadcast에 반응해서, 다른 컴포넌트가 실행 중인 것이 없이 BroadcastReceiver가 단독으로 실행된다면 어떨까?

answer

onReceive() 메서드가 끝나자마자 빈(empty) 프로세스로 우선순위가 낮아지는데 이 때문에 프로세스가 종료될 가능성이 있다. 따라서 AsyncTask 결과가 나올때까지 프로세스가 살아 있다는 것을 보장할 수 없다.

결론적으로 앱 위젯을 업데이트할 때 금방 실행되는 단순한 코드가 아니라면 Service에 넘겨서 Background Thread에서 실행하는 것을 권장한다.

부팅 중에는 initalLayout만 보임


단말 전원을 새로 켜면 홈 스크린 화면이 보인다. 사용자는 홈 스크린이 보이면 부팅이 완료된 것으로 생각하지만 디바이스에서 부팅이 완료되었다고 판단하는 시점과는 차이가 있다. 홈 스크린을 보여주고 나서 내부적으로 여러 작업을 한참 진행하고서야 부팅이 끝났다고 ACTION_BOOT_COMPLETED 액션을 발생시키고, 그 이후에야 ACTION_APPWIDGET_UPDATE 액션을 발생시킨다.
화면이 처음 보일 때에 앱 위젯을 initalLayout 상태 그대로를 보여준다. 부팅이 끝나고서야 앱 위젯을 업데이트하므로, 일정 목록이 없다는 메시지가 보이다가 시간이 꽤 지나고서야 제대로 된 일정이 나타나게 된다.
업데이트할 내용이 있지만 보여줄 수 없는 문제를 보완하기 위해서 일반적으로 initialLayout을 만들 때, 보기 상태를 View.GONE으로 하고, ProgressBar를 포함한 로딩 메시지만을 기본으로 보이게 한다.

ICS부터 기본 패딩


ICS 이전에는 셀 경계선까지 꽉 채워서 앱 위젯이 배치됐었다. targetSdkVersion을 14(ICS) 이상으로 하면 앱 위젯 간에 구분을 확실히 하기 위해서 셀 경계와 앱 위젯 테두리 사이에 기본 패딩이 생긴다. 패딩 값은 launcher마다 다를 수 있다.

고해상도 단말에서 Bitmap 생성 시 메모리 문제


앱 위젯의 일반 용도는 단순한 정보를 보여주는 것이다. 그런데 캘린더나 시간표 같은 앱은 사용자 요구에 의해 앱 위젯의 사이즈가 4x4나 5x5가 되는 경우가 있다. 거의 전체 화면 정도에 정보를 보여주는 것이다. 그리고 RemoteViews에서 지원하는 일반적인 뷰 클래스로는 표현이 복잡해서 Bitmap을 사용하기도 한다. Bitmap에 내용을 그리고 ImageView에 setBitmap()을 실행하는 식이다. 게다가 홈 스크린에서는 화면을 크게 차지할 경우 가급적 바탕 화면도 보이도록 투명하게 만든다. 앞에 조건들이 충족된다면 아래 샘플처럼 Bitmap.createBitmap()으로 Bitmap을 생성하고 Bitmap에 그린다.

1234567891011
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
canvas = new Canvas(bitmap);
canvas.drawrect(...);
canvas.drawText(...);
canvas.drawBitmap(...);
...
// remoteViews.setIamgeViewBitmap(R.id.image, bitmap);
File file = saveImageFile(context, bitmap);
remoteViews.setImageViewUri(R.id.image, Uri.fromFile(file));

createBitmap()의 세 번째 파라미터는 투명 비트맵을 생성하기 위해서 ARGB_8888을 적용한 것이다. 그런데 이 옵션은 픽셀당 4 Bytes를 차지한다. 해상도가 2560x1440인 경우 화면을 거의 채운다고 할 때, createBitmap()만으로 14M 가량의 메모리를 사용하게 되는 셈이다. 앱 실행 중에 앱 위젯 업데이트가 발생한다면 OutOfMemoryError의 원인이 될 수 있다.
기존에는 문제가 없었는데 최신 단말에서 앱 위젯을 생성할 경우 Bitmap.createBitmap()에서 OutOfMemoryError가 발생한다면, 큰 사이즈의 비트맵을 생성한 것이 원인일 가능성이 높다. 단말 스펙이 높아지면서 해상도 역시 좋아지는데, 앱 프로세스의 가용 메모리가 비례해서 커지지 않기 때문에 발생하는 문제이다. 이때는 앱 위젯을 별도 프로세스로 분리하는 것을 고려해야 한다. 앱 위젯 개수가 많다면 그 개수만큼 프로세스를 분리하는 것보다, 서비스에 앱 위젯 업데이트 로직을 넘기고 서비스를 별도 프로세스로 분리하는 게 낫다.
remoteViews.setIamgeViewBitmap(R.id.image, bitmap)를 주석하고 remoteViews.setImageViewUri(R.id.image, Uri.fromFile(file))을 쓴 이유는, remoteViews.setIamgeViewBitmap(R.id.image, bitmap)에서는 바인더에 Bitmap을 전달할 때 Bitmap 사이즈가 크면 에러가 발생하기 때문이다(바인더 트랜잭션 버퍼 최대 크기가 1M). 따라서 일부러 파일을 생성해서 RemoteViews에 Uri로 전달하였다. 이런 방법은 Activity 간에 데이터를 전달할 때도 많이 쓰인다. 사진을 찍은 후 피호출자에 사진을 전달할 때 Intent Bundle에 사진 Bitmap을 담아서 전달하는게 아니라 사진의 파일 Uri를 전달하는 식이다.

Reference


Build an App Widget  |  Android Developers
안드로이드 클라이언트 Reflection 극복기 - VCNC Engineering Blog
Java Reflection 개념 및 사용법