1 package expo.modules.clipboard
2 
3 import android.content.ClipData
4 import android.content.ClipDescription
5 import android.content.ClipboardManager
6 import android.content.Context
7 import android.net.Uri
8 import android.os.Build
9 import androidx.test.core.app.ApplicationProvider
10 import expo.modules.kotlin.exception.CodedException
11 import expo.modules.kotlin.exception.errorCodeOf
12 import expo.modules.test.core.ModuleMock
13 import expo.modules.test.core.ModuleMockHolder
14 import expo.modules.test.core.assertCodedException
15 import io.mockk.confirmVerified
16 import io.mockk.verify
17 import org.junit.Assert.assertEquals
18 import org.junit.Assert.assertFalse
19 import org.junit.Assert.assertTrue
20 import org.junit.Test
21 import org.junit.runner.RunWith
22 import org.robolectric.RobolectricTestRunner
23 import org.robolectric.annotation.Config
24 import org.robolectric.annotation.Implementation
25 import org.robolectric.annotation.Implements
26 import org.robolectric.shadows.ShadowContextImpl
27 
28 private interface ClipboardModuleTestInterface {
29   @Throws(CodedException::class)
getStringAsyncnull30   fun getStringAsync(options: GetStringOptions = GetStringOptions()): String
31 
32   @Throws(CodedException::class)
33   fun setStringAsync(content: String, options: SetStringOptions = SetStringOptions()): Boolean
34 
35   @Throws(CodedException::class)
36   fun hasStringAsync(): Boolean
37 }
38 
39 private inline fun withClipboardMock(
40   block: ModuleMockHolder<ClipboardModuleTestInterface, ClipboardModule>.() -> Unit
41 ) = ModuleMock.createMock(ClipboardModuleTestInterface::class, ClipboardModule(), block = block)
42 
43 @RunWith(RobolectricTestRunner::class)
44 @Config(sdk = [Build.VERSION_CODES.P]) // API 28
45 class ClipboardModuleTest {
46 
47   @Test
48   fun `should save to and read from clipboard`() = withClipboardMock {
49     // write to clipboard
50     val writeResult = module.setStringAsync("album dumbledore")
51 
52     // read from clipboard
53     val readResult = module.getStringAsync()
54 
55     assertTrue(writeResult)
56     assertEquals("album dumbledore", readResult)
57   }
58 
59   @Test
60   fun `should get empty string when clipboard is empty`() = withClipboardMock {
61     // This requires API 28
62     clipboardManager.clearPrimaryClip()
63 
64     val content = module.getStringAsync()
65 
66     assertTrue("Clipboard content should be empty", content.isEmpty())
67   }
68 
69   @Test
70   fun `getStringAsync should support HTML`() = withClipboardMock {
71     clipboardManager.setPrimaryClip(
72       ClipData.newHtmlText(null, "hello world", "<p>hello world</p>")
73     )
74 
75     val plainResult = module.getStringAsync()
76     val htmlResult = module.getStringAsync(
77       GetStringOptions().apply {
78         preferredFormat = StringFormat.HTML
79       }
80     )
81     assertEquals("hello world", plainResult)
82     assertEquals("<p>hello world</p>", htmlResult)
83   }
84 
85   @Test
86   fun `setStringAsync should support HTML`() = withClipboardMock {
87 
88     module.setStringAsync(
89       "<p>hello</p>",
90       SetStringOptions().apply {
91         inputFormat = StringFormat.HTML
92       }
93     )
94 
95     assertTrue(
96       clipboardManager.primaryClipDescription?.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML) == true
97     )
98     assertEquals("<p>hello</p>", clipboardManager.primaryClip!!.getItemAt(0).htmlText)
99   }
100 
101   @Test
102   fun `hasStringAsync should return correct values`() = withClipboardMock {
103     // plain text
104     clipboardManager.setPrimaryClip(ClipData.newPlainText(null, "hello world"))
105     var result = module.hasStringAsync()
106     assertTrue("hasStringAsync returns false for plain text (should be true)", result)
107 
108     // html
109     clipboardManager.setPrimaryClip(
110       ClipData.newHtmlText(null, "hello world", "<p>hello world</p>")
111     )
112     result = module.hasStringAsync()
113     assertTrue("hasStringAsync returns false for plain text (should be true)", result)
114 
115     // non-text content type
116     clipboardManager.setPrimaryClip(ClipData.newRawUri(null, Uri.EMPTY))
117     result = module.hasStringAsync()
118     assertFalse("hasStringAsync returns true for non-text (should be false)", result)
119 
120     // empty clipboard
121     clipboardManager.clearPrimaryClip()
122     result = module.hasStringAsync()
123     assertFalse("hasStringAsync returns true for empty clipboard (should be false)", result)
124   }
125 
126   @Test
127   fun `should emit events when clipboard changes`() = withClipboardMock {
128     // update clipboard content
129     val result = module.setStringAsync("severus snape")
130 
131     // assert
132     assertTrue(result)
133     verify {
134       eventEmitter.emit(
135         CLIPBOARD_CHANGED_EVENT_NAME,
136         match {
137           it.getStringArrayList("contentTypes")?.contains("plain-text") == true
138         }
139       )
140     }
141     confirmVerified(eventEmitter)
142   }
143 
144   @Test
145   fun `shouldn't emit events when in background`() = withClipboardMock {
146     // prepare
147     controller.onActivityEntersBackground()
148 
149     // update clipboard content
150     module.setStringAsync("ronald weasley")
151 
152     // assert that emit() was NOT called
153     verify(inverse = true) { eventEmitter.emit(CLIPBOARD_CHANGED_EVENT_NAME, any()) }
154     confirmVerified(eventEmitter)
155   }
156 
157   @Test
158   @Config(shadows = [ContextWithoutClipboardService::class])
159   fun `should throw when ClipboardManager is unavailable`() = withClipboardMock {
160     val exception = runCatching { module.hasStringAsync() }.exceptionOrNull()
161 
162     assertCodedException(exception) {
163       assertEquals(errorCodeOf<ClipboardUnavailableException>(), it.code)
164     }
165   }
166 
167   private val clipboardManager: ClipboardManager
168     get() = ApplicationProvider
169       .getApplicationContext<Context>()
170       .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
171 }
172 
173 @Implements(className = ShadowContextImpl.CLASS_NAME)
174 class ContextWithoutClipboardService : ShadowContextImpl() {
175   @Implementation
176   override fun getSystemService(name: String): Any? = when (name) {
177     Context.CLIPBOARD_SERVICE -> null
178     else -> super.getSystemService(name)
179   }
180 }
181